Add conversation requests list
- Add routes to REST API for conversation requests
- Add websocket notification on new conversation requests. This is unreliable.
- Rename 'ColoredCallButton' as 'ColoredRoundButton' and move it to Buttons file for reuse
- Review logic to show conversation tabs
- Add useConversationDisplayNameShort for conversations' names in lists. Will need more work.
- Add hooks to help managing React Query's cache
- Use React Query to remove conversations and update the cache doing so.
- Add ContactService and ConversationService as a way to group reusable functions for the server. This is inspired by jami-android
Known bug: The server often freezes on getContactFromUri (in ContactService) when a new conversation request is received.
Change-Id: I46a60a401f09c3941c864afcdb2625b5fcfe054a
diff --git a/server/src/app.ts b/server/src/app.ts
index 0e1b055..39dbffa 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -27,6 +27,7 @@
import { authRouter } from './routers/auth-router.js';
import { callRouter } from './routers/call-router.js';
import { contactsRouter } from './routers/contacts-router.js';
+import { conversationRequestRouter } from './routers/conversation-request-router.js';
import { conversationRouter } from './routers/conversation-router.js';
import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
import { linkPreviewRouter } from './routers/link-preview-router.js';
@@ -55,6 +56,7 @@
this.app.use('/contacts', contactsRouter);
this.app.use('/default-moderators', defaultModeratorsRouter);
this.app.use('/conversations', conversationRouter);
+ this.app.use('/conversation-requests', conversationRequestRouter);
this.app.use('/calls', callRouter);
this.app.use('/link-preview', linkPreviewRouter);
this.app.use('/ns', nameserverRouter);
diff --git a/server/src/jamid/conversation-request-metadata.ts b/server/src/jamid/conversation-request-metadata.ts
index d6c187b..28f6208 100644
--- a/server/src/jamid/conversation-request-metadata.ts
+++ b/server/src/jamid/conversation-request-metadata.ts
@@ -19,4 +19,8 @@
id: string;
from: string;
received: string;
+ avatar: string;
+ description: string;
+ title: string;
+ mode: string;
}
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 07fa2dd..9e078bd 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -118,9 +118,12 @@
getConversations(accountId: string): StringVect;
conversationInfos(accountId: string, conversationId: string): StringMap;
getConversationMembers(accountId: string, conversationId: string): VectMap;
- acceptConversationRequest(accountId: string, conversationId: string): void;
removeConversation(accountId: string, conversationId: string): void;
+ getConversationRequests(accountId: string): VectMap;
+ acceptConversationRequest(accountId: string, conversationId: string): void;
+ declineConversationRequest(accountId: string, conversationId: string): void;
+
sendMessage(accountId: string, conversationId: string, message: string, replyTo: string, flag: number): void;
loadConversationMessages(accountId: string, conversationId: string, fromMessage: string, n: number): number;
setIsComposing(accountId: string, conversationId: string, isWriting: boolean): void;
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index ab4d8a8..15ff23c 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -72,7 +72,7 @@
export class Jamid {
private jamiSwig: JamiSwig;
private usernamesToAccountIds = new Map<string, string>();
- private readonly events;
+ readonly events;
constructor(private webSocketServer: WebSocketServer) {
this.jamiSwig = require('../../jamid.node') as JamiSwig;
@@ -276,6 +276,7 @@
async lookupAddress(address: string, accountId?: string): Promise<LookupResult> {
const hasRingNs = this.jamiSwig.lookupAddress(accountId || '', '', address);
+
if (!hasRingNs) {
throw new Error('Jami does not have a nameserver');
}
@@ -375,6 +376,26 @@
this.jamiSwig.removeConversation(accountId, conversationId);
}
+ getConversationRequests(accountId: string): ConversationRequestMetadata[] {
+ return vectMapToRecordArray(
+ this.jamiSwig.getConversationRequests(accountId)
+ ) as unknown as ConversationRequestMetadata[];
+ }
+
+ acceptConversationRequest(accountId: string, conversationId: string): Promise<ConversationReady> {
+ this.jamiSwig.acceptConversationRequest(accountId, conversationId);
+ return firstValueFrom(
+ this.events.onConversationReady.pipe(
+ filter((value) => value.accountId === accountId),
+ filter((value) => value.conversationId === conversationId)
+ )
+ );
+ }
+
+ declineConversationRequest(accountId: string, conversationId: string): void {
+ this.jamiSwig.declineConversationRequest(accountId, conversationId);
+ }
+
sendConversationMessage(
accountId: string,
conversationId: string,
@@ -477,13 +498,7 @@
this.events.onConversationRequestReceived.subscribe((signal) => {
log.debug('Received ConversationRequestReceived:', JSON.stringify(signal));
-
- // TODO: Prompt user to accept conversation request on client
- // Currently, we auto-accept all incoming conversation requests. In future, we
- // need to ask the user if they accept the conversation request or not. Part of
- // it can be done by sending a WebSocket event.
- // See other implementations e.g. block contact / decline request / accept request.
- this.jamiSwig.acceptConversationRequest(signal.accountId, signal.conversationId);
+ //this.webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationRequest, data);
});
this.events.onConversationReady.subscribe((signal) => {
diff --git a/server/src/routers/conversation-request-router.ts b/server/src/routers/conversation-request-router.ts
new file mode 100644
index 0000000..f0ef566
--- /dev/null
+++ b/server/src/routers/conversation-request-router.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { Router } from 'express';
+import asyncHandler from 'express-async-handler';
+import { HttpStatusCode, IConversationRequest } from 'jami-web-common';
+import { Container } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+import { authenticateToken } from '../middleware/auth.js';
+import { ConversationService } from '../services/ConversationService.js';
+
+const jamid = Container.get(Jamid);
+const conversationService = Container.get(ConversationService);
+
+export const conversationRequestRouter = Router();
+
+conversationRequestRouter.use(authenticateToken);
+
+conversationRequestRouter.get(
+ '/',
+ asyncHandler(async (_req, res) => {
+ const accountId = res.locals.accountId;
+ const jamidRequests = jamid.getConversationRequests(accountId);
+ Promise.all(
+ jamidRequests.map((jamidRequest) => conversationService.createConversationRequest(accountId, jamidRequest))
+ )
+ .then((apiRequests: IConversationRequest[]) => res.send(apiRequests))
+ .catch((err) => res.status(HttpStatusCode.InternalServerError).send(err.message));
+ })
+);
+
+conversationRequestRouter.post(
+ '/:conversationId',
+ asyncHandler(async (req, res) => {
+ const accountId = res.locals.accountId;
+ const conversationId = req.params.conversationId;
+ await jamid.acceptConversationRequest(accountId, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
+ if (conversationSummary === undefined) {
+ res.status(HttpStatusCode.NotFound).send('No such conversation found');
+ return;
+ }
+ res.send(conversationSummary);
+ })
+);
+
+conversationRequestRouter.delete(
+ '/:conversationId',
+ asyncHandler(async (req, res) => {
+ jamid.declineConversationRequest(res.locals.accountId, req.params.conversationId);
+ res.sendStatus(HttpStatusCode.NoContent);
+ })
+);
+
+conversationRequestRouter.post('/:conversationId/block', (req, res) => {
+ const accountId = res.locals.accountId;
+ const conversationId = req.params.conversationId;
+ const conversationRequests = jamid.getConversationRequests(accountId);
+ const conversationRequest = conversationRequests.filter((request) => request.id === conversationId)[0];
+ if (!conversationRequest) {
+ res.status(HttpStatusCode.NotFound).send('No such conversation request found');
+ }
+ jamid.blockContact(accountId, conversationRequest.from);
+ res.sendStatus(HttpStatusCode.NoContent);
+});
diff --git a/server/src/routers/conversation-router.ts b/server/src/routers/conversation-router.ts
index aa65723..c38e46d 100644
--- a/server/src/routers/conversation-router.ts
+++ b/server/src/routers/conversation-router.ts
@@ -22,8 +22,6 @@
ContactDetails,
HttpStatusCode,
IConversationMember,
- IConversationSummary,
- Message,
NewConversationRequestBody,
NewMessageRequestBody,
} from 'jami-web-common';
@@ -31,50 +29,10 @@
import { Jamid } from '../jamid/jamid.js';
import { authenticateToken } from '../middleware/auth.js';
+import { ConversationService } from '../services/ConversationService.js';
const jamid = Container.get(Jamid);
-
-async function createConversationSummary(
- accountId: string,
- accountUri: string,
- conversationId: string
-): Promise<IConversationSummary | undefined> {
- const infos = jamid.getConversationInfos(accountId, conversationId);
- if (Object.keys(infos).length === 0) {
- return undefined;
- }
-
- const members = jamid.getConversationMembers(accountId, conversationId);
-
- const membersNames = [];
- for (const member of members) {
- // Exclude current user from returned conversation members
- if (member.uri === accountUri) {
- continue;
- }
-
- // Add usernames for conversation members
- const { username } = await jamid.lookupAddress(member.uri, accountId);
- membersNames.push(username || member.uri);
- }
-
- let lastMessage: Message | undefined;
- // Skip "merge" type since they are of no interest for the user
- // Should we add some protection to prevent infinite loop?
- while (!lastMessage || lastMessage.type === 'merge') {
- lastMessage = (
- await jamid.getConversationMessages(accountId, conversationId, lastMessage?.linearizedParent || '', 1)
- )[0];
- }
-
- return {
- id: conversationId,
- avatar: infos.avatar,
- title: infos.title,
- membersNames,
- lastMessage,
- };
-}
+const conversationService = Container.get(ConversationService);
export const conversationRouter = Router();
@@ -85,14 +43,11 @@
asyncHandler(async (_req, res) => {
const accountId = res.locals.accountId;
- // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
- const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
-
const conversationIds = jamid.getConversationIds(accountId);
const conversationsSummaries = [];
for (const conversationId of conversationIds) {
- const conversationSummary = await createConversationSummary(accountId, accountUri, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
conversationsSummaries.push(conversationSummary);
}
@@ -132,10 +87,7 @@
const accountId = res.locals.accountId;
const conversationId = req.params.conversationId;
- // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
- const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
-
- const conversationSummary = await createConversationSummary(accountId, accountUri, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
if (conversationSummary === undefined) {
res.status(HttpStatusCode.NotFound).send('No such conversation found');
return;
@@ -204,13 +156,11 @@
asyncHandler(async (req, res) => {
const accountId = res.locals.accountId;
const conversationId = req.params.conversationId;
-
const infos = jamid.getConversationInfos(accountId, conversationId);
if (Object.keys(infos).length === 0) {
res.status(HttpStatusCode.NotFound).send('No such conversation found');
return;
}
-
const messages = await jamid.getConversationMessages(accountId, conversationId);
res.send(messages);
})
diff --git a/server/src/services/ContactService.ts b/server/src/services/ContactService.ts
new file mode 100644
index 0000000..1ec626f
--- /dev/null
+++ b/server/src/services/ContactService.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+import { IContact } from 'jami-web-common';
+import { Container, Service } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+
+const jamid = Container.get(Jamid);
+
+@Service()
+export class ContactService {
+ async getContactFromUri(accountId: string, contactUri: string): Promise<IContact> {
+ const { username } = await jamid.lookupAddress(contactUri, accountId);
+
+ return {
+ uri: contactUri,
+ registeredName: username,
+ };
+ }
+}
diff --git a/server/src/services/ConversationService.ts b/server/src/services/ConversationService.ts
new file mode 100644
index 0000000..5265c16
--- /dev/null
+++ b/server/src/services/ConversationService.ts
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program. If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+
+import { IConversationRequest, IConversationSummary, Message, WebSocketMessageType } from 'jami-web-common';
+import { Container, Service } from 'typedi';
+
+import { ConversationRequestMetadata } from '../jamid/conversation-request-metadata.js';
+import { Jamid } from '../jamid/jamid.js';
+import { WebSocketServer } from '../websocket/websocket-server.js';
+import { ContactService } from './ContactService.js';
+
+const jamid = Container.get(Jamid);
+const webSocketServer = Container.get(WebSocketServer);
+const contactService = Container.get(ContactService);
+
+@Service()
+export class ConversationService {
+ constructor() {
+ jamid.events.onConversationRequestReceived.subscribe(async (signal) => {
+ const conversationRequest = await this.createConversationRequest(signal.accountId, signal.metadata);
+ webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationRequest, conversationRequest);
+ });
+ }
+
+ async createConversationRequest(
+ accountId: string,
+ jamidRequest: ConversationRequestMetadata
+ ): Promise<IConversationRequest> {
+ const contact = await contactService.getContactFromUri(accountId, jamidRequest.from);
+ const membersNames = await this.getMembersNames(accountId, jamidRequest.id);
+
+ // Currently, this does not actually works.
+ // It seems the members can't be accessed yet. Same on jami-android
+ if (membersNames.length === 0) {
+ membersNames.push(contact.registeredName || contact.uri);
+ }
+
+ return {
+ conversationId: jamidRequest.id,
+ received: jamidRequest.received,
+ from: contact,
+ infos: {
+ // Build a dataUrl from the avatar
+ // TODO: Host the image and use a "normal" url instead
+ avatar: `data:image/jpeg;base64,${jamidRequest.avatar}`,
+ description: jamidRequest.description,
+ mode: jamidRequest.mode,
+ title: jamidRequest.title,
+ },
+ membersNames,
+ };
+ }
+
+ async createConversationSummary(
+ accountId: string,
+ conversationId: string
+ ): Promise<IConversationSummary | undefined> {
+ const infos = jamid.getConversationInfos(accountId, conversationId);
+ if (Object.keys(infos).length === 0) {
+ return undefined;
+ }
+ // Build a dataUrl from the avatar
+ // TODO: Host the image and use a "normal" url instead
+ infos.avatar = `data:image/jpeg;base64,${infos.avatar}`;
+
+ const membersNames = await this.getMembersNames(accountId, conversationId);
+ let lastMessage: Message | undefined;
+ // Skip "merge" type since they are of no interest for the user
+ // Should we add some protection to prevent infinite loop?
+ while (!lastMessage || lastMessage.type === 'merge') {
+ lastMessage = (
+ await jamid.getConversationMessages(accountId, conversationId, lastMessage?.linearizedParent || '', 1)
+ )[0];
+ }
+
+ return {
+ id: conversationId,
+ avatar: infos.avatar,
+ title: infos.title,
+ membersNames,
+ lastMessage,
+ };
+ }
+
+ async getMembersNames(accountId: string, conversationId: string): Promise<string[]> {
+ // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
+ const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
+
+ const members = jamid.getConversationMembers(accountId, conversationId);
+ const membersNames: string[] = [];
+ for (const member of members) {
+ // Exclude current user from returned conversation members
+ if (member.uri === accountUri) {
+ continue;
+ }
+
+ // Add usernames for conversation members
+ const { username } = await jamid.lookupAddress(member.uri, accountId);
+ membersNames.push(username || member.uri);
+ }
+
+ return membersNames;
+ }
+}