Chat Application Based on Squid

QuantumSoft
16 min readApr 7, 2024

By Alexander Belov, senior front-end developer at QuantumSoft

Few Words About Me

Hey! My name is Alex. And today I’ll tell you about Squid Cloud service. Together we’ll explore how Squid can help to easily build a real-time chat application using Next.js (and React).

First, let me start with a few words about myself. I’m a well-experienced front-end developer. I worked on many exciting projects, big and small ones. Most of them use Angular or React with REST, JSON RPC, or GraphQL. So these are the technologies, on which I specialize.

The Project

Now let’s talk about the project. Our task is to build a real-time chat application based on Next.js framework. The application should have a general room, available for everyone. Also, it should have an option to create private rooms with other users. For the backend we will use Squid.cloud. As an auth provider, we will use Auth0. (Squid also supports Cognito and JWT RSA).

Why Next.js? Just because it’s a very handy and simple framework with a set of features available out of the box. What I personally like about it is that it’s highly structured meaning it’s convenient to work with. The application routes are reflected on the file system; components and services, etc. are placed in corresponding directories — so, you always know where to find things and where to put things.

First Steps

Assume we have a Next.js project created using create-next-app. Also, we’ll need a set of components to build our UI (it can be a 3rd party library, like MUI).

The next step is to install Auth0 and Squid React SDKs. It’s pretty simple:

npm install --save @auth0/auth0-react
npm install --save @squidcloud/react

We will use Auth0Provider component and useAuth hook from auth0-react. Let’s wrap our application to Auth0Provider.

src/app/layout.tsx:

... 
<html lang="en">
<body>
<Auth0Provider
domain={process.env.NEXT_PUBLIC_AUTH0_DOMAIN}
clientId={process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID}
authorizationParams={{
redirect_uri: process.env.NEXT_PUBLIC_AUTH0_URL_REDIRECT_TO_AFTER_LOGIN,
response_type: 'token id_token',
scope: 'openid profile',
}}
cacheLocation="localstorage"
>
{children}
</AuthProvider>
</body>
</html>
...

NEXT_PUBLIC_AUTH0_DOMAIN, NEXT_PUBLIC_AUTH0_CLIENT_ID values can be taken from Auth0 dashboard. All the environment variables are stored in .env.local.

To make our application interact with Squid, we will create SquidProvider component, which integrates Squid with Auth0.

src/app/components/SquidProvider.tsx:

import { EnvironmentId, SupportedSquidRegion } from '@squidcloud/common';
import { PropsWithChildren } from 'react';
import { SquidContextProvider } from '@squidcloud/react';
import { useAuth0 } from '@auth0/auth0-react';
export function SquidProvider({
children,
}: PropsWithChildren): JSX.Element {
const { user, getAccessTokenSilently } = useAuth0();
return (
<SquidContextProvider
options={{
appId: process.env.NEXT_PUBLIC_SQUID_APP_ID || '',
region: (process.env.NEXT_PUBLIC_SQUID_REGION || '') as SupportedSquidRegion,
environmentId: (process.env.NEXT_PUBLIC_SQUID_ENVIRONMENT_ID || '') as EnvironmentId,
authProvider: {
integrationId: process.env.NEXT_PUBLIC_SQUID_AUTH_INTEGRATION_ID,
getToken: async () => user && (await getAccessTokenSilently()),
},
}}
>
{children}
</SquidContextProvider>
);
}

NEXT_PUBLIC_SQUID_APP_ID, NEXT_PUBLIC_SQUID_REGION, NEXT_PUBLIC_SQUID_ENVIRONMENT_ID and NEXT_PUBLIC_SQUID_AUTH_INTEGRATION_ID values can be get from Squid dashboard.

Now let’s also wrap our application to SquidProvider.

src/app/layout.tsx:

... 
<html lang="en">
<body>
<Auth0Provider
...
>
<SquidProvider>
{children}
</SquidProvider>
</AuthProvider>
</body>
</html>
...

So, we can make the application alive! In other words, now we have an API for creating / login users and we have access to Squid backend functionality, which means we can execute functions and get collections data.

Types Reuse

It’s nice to have shared types between the backend and the frontend.

Here is a simple way to organize types reuse. First of all, put all the code into a monorepo. Let’s say the repo has the following structure:

squid-chat
├── backend
│ └── ...
└── frontend
└── ...

Create package.json file in the repo root directory:

{
"name": "squid-chat",
}

Run the following commands to create a workspace for the backend and the frontend:

npm init -w backend --yes
npm init -w frontend --yes

Flag -w points npm init to create a workspace. Flag — yes tells npm init to use default options without prompting a user.

Run the command to create a workspace for shared code:

npm init -w shared --yes

Open shared/package.json and change name value:

{
"name": "@squid-chat/shared"
...
}

Run the command to be sure that all the modules are installed successfully and workspaces are available:

npm install

Now we can create shared/types directory and put there all data types definition files and use them in the frontend code the following way:

import { User } from '@squid-chat/shared/types/User';

The final package.json should look like this:

{
"name": "squid-chat",
"workspaces": [
"shared",
"frontend",
"backend"
]
}

And here is the final repo structure:

squid-chat
├── backend
│ └── ...
├── frontend
│ └── ...
├── node_modules
│ └── ...
├── package-lock.json
├── package.json
└── shared
├── package.json
└── types
├── Message.ts
├── Room.ts
├── User.ts
├── UserRoom.ts
└── index.ts

Now the frontend part can get the latest types definitions by simple repository pulling. And if some data type is changed, TypeScript compiler will help us quickly find the change and support it.

Saving User Info: Document Update and Function Execution

In Squid, you can create or change a document directly. If you need to change several documents by one call or you need some complex logic behind the change, we can use executable functions. Let me demonstrate both ways. That’s how we can create a user document:

await executeFunction('createUser', {
firstName,
lastName
});

It’s pretty simple, isn’t it?

That’s how we can update a user presence. It helps understand whether a user is online or not. The code is also pretty simple:

await collection<User>('Users').doc(id).update({
lastSeen: new Date()
});

The user document ID here is the Auth0 user ID (sub). Please, see the article about the application’s backend to understand how it works.

Note: In real-life projects, I wouldn’t recommend to pass current date from a client to a backend, just because the date can be wrong or compromised.

Let’s combine this code into a component that allows us to register a new user, get information about a current user, and update this information. Considering “registration” means we know the user’s name.

src/app/components/UserProvider.tsx:

import {
PropsWithChildren,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useSquid } from '@squidcloud/react';
const PRESENCE_REFRESHING_INTERVAL = 30 * 1000;type UserContext = {
id: string | null;
isRegistered: boolean;
firstName: string | null;
lastName: string | null;
isOnline: boolean;
timeZone: string | null;
register({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}): Promise<void>;
update({
firstName,
lastName,
timeZone,
}: {
firstName?: string;
lastName?: string;
timeZone?: string;
}): Promise<void>;
};
const UserContext = createContext<UserContext | null>(null);export function UserProvider({
children,
}: PropsWithChildren): JSX.Element {
const { isAuthenticated, user } = useAuth0();
const { collection, executeFunction } = useSquid();
const [id, setId] = useState<string | null>(null);
const [firstName, setFirstName] = useState<string | null>(null);
const [lastName, setLastName] = useState<string | null>(null);
const [timeZone, setTimeZone] = useState<string | null>(null);
const isRegistered = useMemo(
() => Boolean(firstName && lastName),
[firstName, lastName]
);
const isOnline = useMemo(
() => Boolean(id) && isRegistered,
[id, isRegistered]
);
const register = useCallback(
async ({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}): Promise<void> => {
await executeFunction('createUser', {
firstName,
lastName,
});
setFirstName(firstName);
setLastName(lastName);
},
[executeFunction]
);
const update = useCallback(
async ({
firstName: newFirstName,
lastName: newLastName,
timeZone: newTimeZone,
}: {
firstName?: string;
lastName?: string;
timeZone?: string;
}) => {
await executeFunction('updateUser', {
firstName: newFirstName || firstName,
lastName: newLastName || lastName,
timeZone: newTimeZone || timeZone,
});
setFirstName(newFirstName || firstName);
setLastName(newLastName || lastName);
setTimeZone(newTimeZone || timeZone);
},
[executeFunction, firstName, lastName, timeZone]
);
const refreshPresence = useCallback(async () => {
if (!id) {
return;
}
await collection('Users').doc(id).update({
lastSeen: new Date(),
});
}, [collection, id]);
useEffect(() => {
if (!id) {
return;
}
refreshPresence(); const presenceRefreshingInterval = setInterval(async () => {
refreshPresence();
}, PRESENCE_REFRESHING_INTERVAL);
return () => {
clearInterval(presenceRefreshingInterval);
};
}, [id, refreshPresence]);
const userContextValue = useMemo(
() => ({
id,
isRegistered,
firstName,
lastName,
isOnline,
timeZone,
register,
update,
}),
[
id,
isRegistered,
firstName,
lastName,
isOnline,
timeZone,
register,
update,
]
);
return (
<UserContext.Provider value={userContextValue}>
{children}
</UserContext.Provider>
);
}

Now we have a component providing a context, which can be used within useContext hook, so we can work with thecurrent user inside the app. Of course, we need to wrap the app to this component:

src/app/layout.tsx:

... 
<html lang="en">
<body>
<Auth0Provider
...
>
<SquidProvider>
<UserProvider>
{children}
</UserProvider>
</SquidProvider>
</AuthProvider>
</body>
</html>
...

Getting User Info: Simple Collection Document Subscription

Okay, we have stored some data in the database. Now we want to get the data. Here is an example how to get the latest snapshot of a user document:

const userDocument = await collection('Users').doc(userId).snapshot();

But what if we want to get the latest snapshot on each document update? For that, Squid gives a fantastic mechanism based on RxJS’s subscriptions — we can just subscribe on snapshots changes of some document. Let’s use this mechanism in our UserProvider to have the current user up to date.

src/app/components/UserProvider.tsx:

...
import { Subscription } from 'rxjs';...export function UserProvider({
children,
}: PropsWithChildren): JSX.Element {
const { isAuthenticated, user } = useAuth0();
const { collection, executeFunction } = useSquid();
const [id, setId] = useState<string | null>(null);
const [firstName, setFirstName] = useState<string | null>(null);
const [lastName, setLastName] = useState<string | null>(null);
const [timeZone, setTimeZone] = useState<string | null>(null);
... useEffect(() => {
let userDocumentSubscription: Subscription | undefined;
setId(null);
setFirstName(null);
setLastName(null);
const userId = user?.sub; if (isAuthenticated && userId) {
userDocumentSubscription = collection('Users')
.doc(userId)
.snapshots()
.subscribe((userDocument) => {
setId(userId);
setFirstName(userDocument?.data.firstName || null);
setLastName(userDocument?.data.lastName || null);
setTimeZone(
userDocument?.data.timeZone ||
Intl.DateTimeFormat().resolvedOptions().timeZone
);
});
}
return () => {
userDocumentSubscription?.unsubscribe();
};
}, [isAuthenticated, user?.sub, collection]);
...
}

Note: to avoid memory leaking, don’t forget to call unsubscribe() method of a document subscription in a useEffect cleanup function.

Getting users, rooms, and messages: queries and joins

We have an authentication integrated, we can get and update the data of the current user. The last step is to create a component providing an API for chats. Let’s describe the API:

src/app/components/ChatsProvider.tsx:

type ChatsContext = {
users: ChatUser[] | null;
roomsByRoomId: Record<string, ChatRoom> | null;
  createChatRoom({
name,
icon,
membersIds,
}: {
name: string;
icon?: string;
membersIds: string[];
}): Promise<ChatRoom>;
sendTextMessage({
roomId,
message,
}: {
roomId: string;
message: string;
}): Promise<void>;
sendPollMessage({
roomId,
question,
options,
duration,
}: {
roomId: string;
question: string;
options: string[];
duration: number;
}): Promise<void>;
resendNotSentMessage(message: NewChatMessage): Promise<void>; deleteNotSentMessage(message: NewChatMessage): Promise<void>; voteInPoll({
messageId,
optionId,
}: {
messageId: string;
optionId: string;
}): Promise<void>;
};

Here are the used types:

src/app/components/ChatsProvider.tsx:

type ChatUser = {
id: string;
firstName: string;
lastName: string;
isOnline: boolean;
};
type ChatRoom = {
type: 'default' | 'breakout';
id: string;
createdAt: Date;
name: string;
icon?: EmojiPickerEmoji;
members: ChatUser[];
messages: (ChatMessage | NewChatMessage)[];
};
export type ChatMessage = {
id: string;
status: ChatMessageStatus.Sent;
} & Omit<NewChatTextMessage | NewChatPollMessage<ChatPollOption>, 'status'>;
export type NewChatMessage =
| NewChatTextMessage
| NewChatPollMessage<NewChatMessagePollOption>;
export type NewChatTextMessage = {
roomId: string;
type: ChatMessageType.Text;
status: ChatMessageStatus.Pending | ChatMessageStatus.Error;
from: ChatUser;
createdAt: Date;
message: string;
};
export type NewChatPollMessage<PollOption = NewChatMessagePollOption> = {
roomId: string;
type: ChatMessageType.Poll;
status: ChatMessageStatus.Pending | ChatMessageStatus.Error;
from: ChatUser;
createdAt: Date;
question: string;
options: PollOption[];
duration: number;
};
export enum ChatMessageType {
Text,
Poll,
}
export enum ChatMessageStatus {
Pending,
Error,
Sent,
}
export type ChatPollOption = {
id: string;
text: string;
votes: string[];
};
export type NewChatMessagePollOption = Omit<ChatPollOption, 'id'>;

There are a lot of types. Only well-defined types make our code safer and more organized.

Let me explain the code. We need types for a user and a room. A chat room has users and messages. A message can be a text message or a poll, with its options. When a message is not sent, it has no ID. A sent message has “Sent” status. That’s what we described here.

Now, let’s implement the interface. We’ll start with creating a chat room. Here is the code:

src/app/components/ChatsProvider.tsx:

...
export function ChatsProvider({ children }: PropsWithChildren): JSX.Element {
const { collection, executeFunction } = useSquid();
const [users, setUsers] = useState<ChatUser[] | null>(null);
const [roomsByRoomId, setRoomsByRoomId] = useState<Record<string, ChatRoom> | null>(null);
... const createChatRoom = useCallback(
async ({
name,
icon,
membersIds,
}: {
name: string;
icon?: EmojiPickerEmoji;
membersIds: string[];
}): Promise<ChatRoom> => {
if (!users) {
throw new Error('There is no users');
}
const createRoomResult = await executeFunction('createRoom', {
name,
icon,
});
const chatRoomId = createRoomResult.__id; await executeFunction('addUsersToChatRoom', {
roomId: chatRoomId,
userIds: membersIds,
});
const chatRoomMembers = users.filter((chatUser: ChatUser) =>
membersIds.includes(chatUser.id)
);
const newChatRoom = {
id: chatRoomId,
type: createRoomResult.type,
createdAt: new Date(createRoomResult.created),
name,
icon,
members: chatRoomMembers,
messages: [],
};
setRoomsByRoomId((prevRoomsByRoomId) => ({
...prevRoomsByRoomId,
[newChatRoom.id]: newChatRoom,
}));
return newChatRoom;
},
[executeFunction, users]
);
...
}

There’s nothing new here. We create a room by executing createRoom function. Once a room is created, we add users to it (addUsersToChatRoom). Then we store the room to roomsByRoomId state variable. You also may see that users state variable is used, I’ll show you how it becomes set later.

Let me demonstrate how to send a message in a room.

src/app/components/ChatsProvider.tsx:

...
export function ChatsProvider({ children }: PropsWithChildren): JSX.Element {
const user = useUser();
const { collection, executeFunction } = useSquid();
... const [messagesByRoomId, setMessagesByRoomId] = useState<Record<string, ChatMessage[]> | null>(null);
const [notSentMessagesByRoomId, setNotSentMessagesByRoomId] = useState<Record<string, NewChatMessage[]>>({});
const currentUser: ChatUser | null = useMemo(
() =>
user.id
? {
id: user.id,
firstName: user.firstName || '',
lastName: user.lastName || '',
isOnline: user.isOnline,
}
: null,
[user]
);
... const sendPollOrTextMessageOptimistically = useCallback(
async ({
roomId,
message = '',
question,
options,
duration,
}: {
roomId: string;
message?: string;
question?: string;
options?: string[];
duration?: number;
}) => {
if (!currentUser) {
throw new Error('There is no current user');
}
let newChatMessage: NewChatMessage; if (question && options && duration) {
newChatMessage = {
roomId,
type: ChatMessageType.Poll,
status: ChatMessageStatus.Pending,
from: currentUser,
createdAt: new Date(),
question,
options: options.map((optionText: string) => ({
text: optionText,
votes: [],
})),
duration,
};
} else {
newChatMessage = {
roomId,
type: ChatMessageType.Text,
status: ChatMessageStatus.Pending,
from: currentUser,
createdAt: new Date(),
message,
};
}
setNotSentMessagesByRoomId((prevNotSentMessagesByRoomId) => {
const newRoomNotSentMessages = [
...(prevNotSentMessagesByRoomId[roomId] || []),
newChatMessage,
];
return {
...prevNotSentMessagesByRoomId,
[roomId]: newRoomNotSentMessages,
};
});
try {
const createMessageResult =
await executeFunction<MessagesCollectionDocument>('createMessage', {
roomId,
text: message,
pollName: question,
pollOptions: options,
pollDuration: duration,
});
setNotSentMessagesByRoomId((prevNotSentMessagesByRoomId) => {
const newRoomNotSentMessages = (prevNotSentMessagesByRoomId[roomId] || []).filter(
(notSentMessage: NewChatMessage) => notSentMessage !== newChatMessage
);
return {
...prevNotSentMessagesByRoomId,
[roomId]: newRoomNotSentMessages,
};
});
setMessagesByRoomId((prevMessagesByRoomId) => {
const newChatMessagesByRoomId = { ...prevMessagesByRoomId };
const newRoomMessage = messageDocumentToChatMessage(createMessageResult);
const newRoomMessages = (newChatMessagesByRoomId[roomId] || []).filter(
(message: ChatMessage) => message.id !== newRoomMessage.id
);
newRoomMessages.push({
...newRoomMessage,
from: newChatMessage.from,
});
newChatMessagesByRoomId[roomId] = newRoomMessages; return newChatMessagesByRoomId;
});
} catch (unknownError) {
newChatMessage.status = ChatMessageStatus.Error;
setNotSentMessagesByRoomId((prevNotSentMessagesByRoomId) => {
const newRoomNotSentMessages = (prevNotSentMessagesByRoomId[roomId] || [])
.filter((notSentMessage: NewChatMessage) => notSentMessage !== newChatMessage)
.concat(newChatMessage);
return {
...prevNotSentMessagesByRoomId,
[roomId]: newRoomNotSentMessages,
};
});
}
},
[currentUser, executeFunction]
);
const sendTextMessage = useCallback(
async ({ roomId, message }: { roomId: string; message: string }) => {
await sendPollOrTextMessageOptimistically({
roomId,
message,
});
},
[sendPollOrTextMessageOptimistically]
);
const sendPollMessage = useCallback(
async ({
roomId,
question,
options,
duration,
}: {
roomId: string;
question: string;
options: string[];
duration: number;
}) => {
await sendPollOrTextMessageOptimistically({
roomId,
question,
options,
duration,
});
},
[sendPollOrTextMessageOptimistically]
);
...
}

Here we have two functions: sendTextMessage and sendPollMessage, both call sendPollOrTextMessageOptimistically. Inside sendPollOrTextMessageOptimistically, we create a new chat message and store it to notSentMessagesByRoomId state variable. Then we try to send the message and if it’s successfully sent, we remove the message from notSentMessagesByRoomId and store it to messagesByRoomId. If the sending failed, we set the message status to Error, remove the message from notSentMessagesByRoomId, and add the message to notSentMessagesByRoomId again, to the end of the room messages array.

Why do we need notSentMessagesByRoomId state variable? To display messages failed in the UI and to resend them or delete when necessary. Just like this:

src/app/components/ChatsProvider.tsx:

...
export function ChatsProvider({ children }: PropsWithChildren): JSX.Element {
...
  const [notSentMessagesByRoomId, setNotSentMessagesByRoomId] = useState<Record<string, NewChatMessage[]>>({});

...
const deleteNotSentMessage = useCallback(
async (messageToDelete: NewChatMessage) => {
const { roomId } = messageToDelete;
setNotSentMessagesByRoomId((prevNotSentMessagesByRoomId) => {
const newRoomNotSentMessages = (
prevNotSentMessagesByRoomId[roomId] || []
).filter(
(notSentMessage: NewChatMessage) => notSentMessage !== messageToDelete
);
return {
...prevNotSentMessagesByRoomId,
[roomId]: newRoomNotSentMessages,
};
});
},
[]
);
const resendNotSentMessage = useCallback(
async (messageToResend: NewChatMessage) => {
deleteNotSentMessage(messageToResend);
if (messageToResend.type === ChatMessageType.Text) {
await sendTextMessage({
roomId: messageToResend.roomId,
message: messageToResend.message,
});
} else if (messageToResend.type === ChatMessageType.Poll) {
await sendPollMessage({
roomId: messageToResend.roomId,
question: messageToResend.question,
options: messageToResend.options.map(({ text }) => text),
duration: messageToResend.duration,
});
} else {
throw new Error(
`Unknown message type: "${(messageToResend as any).type}"`
);
}
},
[deleteNotSentMessage, sendTextMessage, sendPollMessage]
);
...
}

We have almost everything for chats but no information about existing users and chat rooms with members and messages. To get this data, we’ll use queries — a mechanism for getting set of documents. Queries also allow us to do some manipulations with data, like filtering, sorting, and so on. Let me demonstrate how it works:

src/app/components/ChatsProvider.tsx:

import { User as UsersCollectionDocument } from '@squid-chat/shared/types';
const INACTIVITY_DURATION_TO_CONSIDER_USER_OFFLINE = 45 * 1000;function userDocumentToChatUser(
userDocument: UsersCollectionDocument
): ChatUser {
return {
id: userDocument.__id,
firstName: userDocument.firstName || '',
lastName: userDocument.lastName || '',
isOnline: userDocument.lastSeen
? userDocument.lastSeen.getTime() + INACTIVITY_DURATION_TO_CONSIDER_USER_OFFLINE > Date.now()
: false,
};
}
...export function ChatsProvider({ children }: PropsWithChildren): JSX.Element {
const currentUser = useUser();
const { collection, executeFunction } = useSquid();
const [users, setUsers] = useState<ChatUser[] | null>(null); ... useEffect(() => {
if (!currentUser?.isOnline) {
return;
}
const usersQuery = collection<UsersCollectionDocument>('Users').query(); const usersQuerySubscription = usersQuery
.sortBy('firstName')
.sortBy('lastName')
.snapshots()
.subscribe(
(usersDocumentsReferences: DocumentReference<UsersCollectionDocument>[]) => {
const users: ChatUser[] = usersDocumentsReferences.map(({ data }) =>
userDocumentToChatUser(data)
);
setUsers(users);
}
);
return () => {
usersQuerySubscription.unsubscribe();
};
}, [currentUser?.isOnline, collection]);
...
}

First, we define a function userDocumentToChatUser, which maps a user document from the backend to our ChatUser, considering a user is online if the user’s presence was updated less than 45 seconds ago. Then, inside an effect (that runs only if the current user is authenticated), we query users and sort them by first and last names. Then we just map users’ documents to ChatUser and store the result to users state variable. Again, to avoid memory leaking we call unsubscribe() method in the effect’s clean up function.

On the backend side, users are stored in Users collection, chat rooms are stored in Rooms collection, members of rooms are presented as UserRoom collection, and messages are in Messages. To query this data combined Squid has the joins feature. Joins are powerful and pretty complicated, so it’s better to take a look at the official documentation.

Let’s modify the effect above to use joined queries for getting chat rooms with members and messages.

src/app/components/ChatsProvider.tsx:

import { User as UsersCollectionDocument } from '@squid-chat/shared/types';
...function roomDocumentToChatRoom(
roomDocument: RoomsCollectionDocument
): ChatRoom {
return {
id: roomDocument.__id,
type: roomDocument.type,
name: roomDocument.name,
icon: roomDocument.icon as EmojiPickerEmoji,
createdAt: new Date(roomDocument.created),
members: [],
messages: [],
};
}
function messageDocumentToChatMessage(
messageDocument: MessagesCollectionDocument
): Omit<ChatMessage, 'from'> {
if ('pollName' in messageDocument) {
return {
id: messageDocument.__id,
roomId: messageDocument.roomId,
type: ChatMessageType.Poll,
createdAt: new Date(messageDocument.created),
status: ChatMessageStatus.Sent,
question: messageDocument.pollName || '',
options: (messageDocument.pollOptions || []).map((optionText: string, index: number) => ({
id: index,
text: optionText,
votes: messageDocument.pollAnswers?.[index] || [],
})),
duration: messageDocument.pollDuration,
} as Omit<ChatMessage, 'from'>;
} else {
return {
id: messageDocument.__id,
roomId: messageDocument.roomId,
type: ChatMessageType.Text,
createdAt: new Date(messageDocument.created),
status: ChatMessageStatus.Sent,
message: messageDocument.text,
} as Omit<ChatMessage, 'from'>;
}
}
export function ChatsProvider({ children }: PropsWithChildren): JSX.Element {
const currentUser = useUser();
const { collection, executeFunction } = useSquid();
const [users, setUsers] = useState<ChatUser[] | null>(null); const [roomsByRoomId, setRoomsByRoomId] = useState<Record<string, ChatRoom> | null>(null);
const [messagesByRoomId, setMessagesByRoomId] = useState<Record<string, ChatMessage[]> | null>(null);
const [notSentMessagesByRoomId, setNotSentMessagesByRoomId] = useState<Record<string, NewChatMessage[]>>({});
... useEffect(() => {
if (!currentUser?.id) {
return;
}
const usersQuery = collection<UsersCollectionDocument>('Users').query();
const roomsQuery = collection<RoomsCollectionDocument>('Rooms').query();
const userRoomQuery = collection<UserRoomsCollectionDocument>('UserRoom').query();
const messagesQuery = collection<MessagesCollectionDocument>('Messages').query();
const currentUserRoomJoinQuery = collection<UserRoomsCollectionDocument>('UserRoom')
.joinQuery('currentUserRoom')
.eq('userId', currentUser.id);
const usersQuerySubscription = usersQuery
.sortBy('firstName')
.sortBy('lastName')
.snapshots()
.subscribe((usersDocumentsReferences: DocumentReference<UsersCollectionDocument>[]) => {
const users: ChatUser[] = usersDocumentsReferences.map(({ data }) =>
userDocumentToChatUser(data)
);
setUsers(users);
}
);
const currentUserRoomRoomsUserRoomUsersJoinQueryBuilder =
currentUserRoomJoinQuery
.join(roomsQuery, 'room', {
left: 'roomId',
right: '__id',
})
.join(userRoomQuery, 'userRoom', {
left: '__id',
right: 'roomId',
})
.join(usersQuery, 'users', {
left: 'userId',
right: '__id',
})
.grouped()
.dereference();
const currentUserRoomRoomsUserRoomUsersQuerySubscription =
currentUserRoomRoomsUserRoomUsersJoinQueryBuilder
.snapshots()
.subscribe((currentUserRoomRoomsUserRoomUsersQueryResult) => {
const newRoomsByRoomId =
currentUserRoomRoomsUserRoomUsersQueryResult.reduce(
(
newRoomsByRoomId: Record<string, ChatRoom>,
{ room: [{ room: roomDocument, userRoom }] }
) => {
const chatRoom = roomDocumentToChatRoom(roomDocument);
const chatRoomMembers = userRoom.map(
({ users: [userDocument] }) => userDocumentToChatUser(userDocument)
);
newRoomsByRoomId[chatRoom.id] = {
...chatRoom,
members: chatRoomMembers,
};
return newRoomsByRoomId;
},
{}
);
setRoomsByRoomId(newRoomsByRoomId);
});
const currentUserRoomMessagesUserQueryBuilder = currentUserRoomJoinQuery
.join(messagesQuery, 'message', {
left: 'roomId',
right: 'roomId',
})
.limit(MAX_CHAT_MESSAGES_PER_ROOM)
.join(usersQuery, 'user', {
left: 'postedByUserId',
right: '__id',
})
.grouped()
.dereference();
const currentUserMessagesQuerySubscription =
currentUserRoomMessagesUserQueryBuilder
.snapshots()
.subscribe((currentUserRoomMessagesUserQueryResult) => {
const newMessagesByRoomId =
currentUserRoomMessagesUserQueryResult.reduce(
(
newMessagesByRoomId: Record<string, ChatMessage[]>,
{ currentUserRoom, message: messages }
) => {
const chatRoomMessages = messages.reduce(
(
chatRoomMessages: Record<string, ChatMessage>,
{ message, user }
) => {
if (user[0]) {
const chatMessage = messageDocumentToChatMessage(message);
const chatMessageAuthor = userDocumentToChatUser(user[0]);
chatRoomMessages[chatMessage.id] = {
...chatMessage,
from: chatMessageAuthor,
};
}
return chatRoomMessages;
},
{}
);
newMessagesByRoomId[currentUserRoom.roomId] = Object.values(chatRoomMessages); return newMessagesByRoomId;
},
{}
);
setMessagesByRoomId(newMessagesByRoomId);
});
return () => {
usersQuerySubscription.unsubscribe();
currentUserRoomRoomsUserRoomUsersQuerySubscription.unsubscribe();
currentUserMessagesQuerySubscription.unsubscribe();
};
}, [currentUser?.id, collection]);
... const combinedRoomsAndMessagesByRoomId: Record<string, ChatRoom> | null =
useMemo(
() =>
roomsByRoomId && messagesByRoomId
? Object.keys(messagesByRoomId).reduce(
(rooms: Record<string, ChatRoom>, roomId: string) => {
const currentRoom = roomsByRoomId[roomId];
if (currentRoom) {
const currentRoomMessages = [
...messagesByRoomId[roomId],
...(notSentMessagesByRoomId[roomId] || []),
].sort(
(
chatMessageA: ChatMessage | NewChatMessage,
chatMessageB: ChatMessage | NewChatMessage
) =>
chatMessageB.createdAt.getTime() -
chatMessageA.createdAt.getTime()
);
rooms[roomId] = {
...currentRoom,
messages: currentRoomMessages,
};
}
return rooms;
},
{}
)
: null,
[roomsByRoomId, messagesByRoomId, notSentMessagesByRoomId]
);
const chatsContextValue = useMemo(
() => ({
users,
roomsByRoomId: combinedRoomsAndMessagesByRoomId,
createChatRoom,
sendTextMessage,
sendPollMessage,
resendNotSentMessage,
deleteNotSentMessage,
voteInPoll,
}),
[
users,
combinedRoomsAndMessagesByRoomId,
createChatRoom,
sendTextMessage,
sendPollMessage,
resendNotSentMessage,
deleteNotSentMessage,
voteInPoll,
]
);
return (
<ChatsContext.Provider value={chatsContextValue}>
{children}
</ChatsContext.Provider>
);
}

First, we build a query currentUserRoomJoinQuery, which gets all user-room relations where the current user ID is involved. Then we build a query currentUserRoomRoomsUserRoomUsersJoinQueryBuilder, which uses currentUserRoomJoinQuery to get all the data of current user rooms: we have IDs of current user’s rooms — we get the rooms’ info, after that, we get IDs of these rooms members, and then we use these IDs to get info about these members. Then we simply map the result to the application internal types and store the rooms in roomsByRoomId state variable.

Next, we get messages from each room the current user is a member of (currentUserRoomMessagesUserQueryBuilder), limiting the number of the messages by MAX_CHAT_MESSAGES_PER_ROOM. Also, we get info about each message author. Then we map the data to the internal types and store it in messagesByRoomId.

We also have combinedRoomsAndMessagesByRoomId cached variable that depends on roomsByRoomId, messagesByRoomId, and notSentMessagesByRoomId. It’s a final step where we combine all the data in one structure, which can be exposed as a value of ChatsContext.

Since we are done with ChatsProvider, we need to wrap the app to the component:

src/app/layout.tsx:

... 
<html lang="en">
<body>
<Auth0Provider
...
>
<SquidProvider>
<UserProvider>
<ChatsProvider>
{children}
</ChatsProvider>
</UserProvider>
</SquidProvider>
</AuthProvider>
</body>
</html>
...

The result. Pros and cons

Not taking into account the UI, we have everything to build a chat application! Here it is alive: https://chat.squid.cloud (probably you’ll need to allow cross-site tracking in your browser to see the app working). Of course, it’s more complex than some pieces of code I described in this article, but the application is actually based on that code.

What do I think about Squid? Here are the pros and cons.

Pros:

  1. Squid can be used for simple pet projects and big complex applications
  2. Squid allows to focus on application business logic covering all the infrastructure stuff
  3. Squid has nice integration with modern frontend frameworks
  4. Squid API is pretty clean. And Squid team continuously improves the API

Cons:

  1. At this moment Squid SDK is not fully documented, there are some caveats
  2. There are some insignificant mistakes in the documentation

But I believe the Squid team will handle all these problems shortly.

And, as a front-end guy, I can say that any more or less experienced front-end developer can become a full-stack person with Squid. Just try it and you‘ll see its power.

The text above was prepared and accomplished by Alexander Belov. The opinions expressed here are the author’s own and do not necessarily represent the views of QuantumSoft.

--

--