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;
+  }
+}