From 11c2588e457db6d6b94b21177d669acdfe9cabb2 Mon Sep 17 00:00:00 2001 From: Myx Date: Mon, 27 Apr 2026 00:45:16 +0200 Subject: [PATCH] feat: Add pm --- e2e/pages/server-search.page.ts | 2 +- e2e/tests/chat/dm-flow.spec.ts | 117 ++++ .../settings/connectivity-warning.spec.ts | 6 +- .../settings/ice-server-settings.spec.ts | 2 +- e2e/tests/settings/stun-turn-fallback.spec.ts | 4 +- .../voice/mixed-signal-config-voice.spec.ts | 4 +- .../multi-signal-eight-user-voice.spec.ts | 4 +- server/src/routes/servers.ts | 7 +- server/src/services/server-access.service.ts | 4 + toju-app/angular.json | 2 +- toju-app/src/app/app.html | 2 +- toju-app/src/app/app.routes.ts | 10 + toju-app/src/app/app.ts | 9 +- toju-app/src/app/domains/README.md | 2 + .../chat-message-composer.component.html | 1 + .../chat-message-composer.component.ts | 1 + .../chat-message-list.component.ts | 9 + .../user-list/user-list.component.html | 22 +- .../feature/user-list/user-list.component.ts | 20 +- toju-app/src/app/domains/chat/index.ts | 15 + .../src/app/domains/direct-message/README.md | 41 ++ .../services/direct-message.service.spec.ts | 69 +++ .../services/direct-message.service.ts | 566 ++++++++++++++++++ .../services/friend.service.spec.ts | 41 ++ .../application/services/friend.service.ts | 85 +++ .../services/offline-message-queue.service.ts | 23 + .../services/offline-queue.service.spec.ts | 42 ++ .../services/peer-delivery.service.ts | 105 ++++ .../domain/logic/direct-message.logic.ts | 91 +++ .../domain/models/direct-message.model.ts | 39 ++ .../feature/dm-chat/dm-chat.component.html | 99 +++ .../feature/dm-chat/dm-chat.component.ts | 455 ++++++++++++++ .../dm-message/dm-message.component.html | 47 ++ .../dm-message/dm-message.component.ts | 57 ++ .../feature/dm-rail/dm-rail.component.html | 74 +++ .../feature/dm-rail/dm-rail.component.scss | 31 + .../feature/dm-rail/dm-rail.component.ts | 230 +++++++ .../dm-workspace/dm-workspace.component.html | 88 +++ .../dm-workspace/dm-workspace.component.ts | 184 ++++++ .../friend-button.component.html | 14 + .../friend-button/friend-button.component.ts | 31 + .../user-search-list.component.html | 119 ++++ .../user-search-list.component.ts | 128 ++++ .../src/app/domains/direct-message/index.ts | 6 + .../direct-message.repository.ts | 58 ++ .../infrastructure/friend.repository.ts | 49 ++ .../offline-queue.repository.ts | 49 ++ .../server-search.component.html | 367 +++++++----- .../server-search/server-search.component.ts | 97 ++- .../services/server-directory-api.service.ts | 8 +- .../domain/logic/theme-defaults.logic.ts | 32 + .../domain/logic/theme-registry.logic.ts | 24 + .../rooms-side-panel.component.html | 44 +- .../rooms-side-panel.component.ts | 62 +- .../servers-rail/servers-rail.component.html | 4 + .../servers-rail/servers-rail.component.ts | 17 + .../shell/title-bar/title-bar.component.html | 3 +- .../shell/title-bar/title-bar.component.ts | 13 +- toju-app/src/app/shared-kernel/README.md | 1 + toju-app/src/app/shared-kernel/chat-events.ts | 28 +- .../shared-kernel/direct-message-contracts.ts | 54 ++ toju-app/src/app/shared-kernel/index.ts | 1 + .../profile-card/profile-card.component.html | 13 +- .../profile-card/profile-card.component.ts | 34 +- toju-app/src/app/shared/index.ts | 1 + 65 files changed, 3653 insertions(+), 214 deletions(-) create mode 100644 e2e/tests/chat/dm-flow.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/README.md create mode 100644 toju-app/src/app/domains/direct-message/application/services/direct-message.service.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/friend.service.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/friend.service.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/offline-message-queue.service.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/offline-queue.service.spec.ts create mode 100644 toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts create mode 100644 toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts create mode 100644 toju-app/src/app/domains/direct-message/domain/models/direct-message.model.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-message/dm-message.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-message/dm-message.component.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.scss create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts create mode 100644 toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.html create mode 100644 toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts create mode 100644 toju-app/src/app/domains/direct-message/index.ts create mode 100644 toju-app/src/app/domains/direct-message/infrastructure/direct-message.repository.ts create mode 100644 toju-app/src/app/domains/direct-message/infrastructure/friend.repository.ts create mode 100644 toju-app/src/app/domains/direct-message/infrastructure/offline-queue.repository.ts create mode 100644 toju-app/src/app/shared-kernel/direct-message-contracts.ts diff --git a/e2e/pages/server-search.page.ts b/e2e/pages/server-search.page.ts index 7553735..1074905 100644 --- a/e2e/pages/server-search.page.ts +++ b/e2e/pages/server-search.page.ts @@ -22,7 +22,7 @@ export class ServerSearchPage { readonly dialogCancelButton: Locator; constructor(private page: Page) { - this.searchInput = page.getByPlaceholder('Search servers...'); + this.searchInput = page.getByPlaceholder('Search servers and users...'); this.railCreateServerButton = page.locator('button[title="Create Server"]'); this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' }); this.createServerButton = this.searchCreateServerButton; diff --git a/e2e/tests/chat/dm-flow.spec.ts b/e2e/tests/chat/dm-flow.spec.ts new file mode 100644 index 0000000..5f95093 --- /dev/null +++ b/e2e/tests/chat/dm-flow.spec.ts @@ -0,0 +1,117 @@ +import { type Page } from '@playwright/test'; +import { + test, + expect, + type Client +} from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatMessagesPage } from '../../pages/chat-messages.page'; + +test.describe('Direct message flow', () => { + test.describe.configure({ timeout: 180_000 }); + + test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => { + const scenario = await createDmScenario(createClient); + const offlineMessage = `Offline DM ${uniqueName('msg')}`; + + await test.step('Alice opens Bob from the room user list', async () => { + const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); + + await expect(bobUserCard).toBeVisible({ timeout: 20_000 }); + await bobUserCard.getByRole('button', { name: 'Message Bob' }).click(); + await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 }); + await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 }); + }); + + await test.step('Offline send persists locally as queued', async () => { + await scenario.alice.page.evaluate(() => window.simulateOffline?.()); + await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage); + await scenario.alice.page.getByTestId('dm-input').press('Enter'); + + await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 }); + await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED'); + }); + }); + + test('shows friend and message actions on the search people list', async ({ createClient }) => { + const scenario = await createDmScenario(createClient); + + await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' }); + await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 }); + await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 }); + await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 }); + const bobPeopleCard = scenario.alice.page.locator(`app-user-search-list [data-testid="user-card-${scenario.bobUserId}"]`); + + await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 }); + const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`); + const messageButton = bobPeopleCard.locator(`[data-testid="message-user-${scenario.bobUserId}"]`); + + await expect(friendButton).toBeVisible({ timeout: 15_000 }); + await expect(messageButton).toBeVisible({ timeout: 15_000 }); + }); +}); + +interface DmScenario { + alice: Client; + bob: Client; + bobUserId: string; + aliceSearch: ServerSearchPage; +} + +async function createDmScenario(createClient: () => Promise): Promise { + const suffix = uniqueName('dm'); + const serverName = `DM Server ${suffix}`; + const alice = await createClient(); + const bob = await createClient(); + + await registerUser(alice.page, `alice_${suffix}`, 'Alice'); + await registerUser(bob.page, `bob_${suffix}`, 'Bob'); + + const aliceSearch = new ServerSearchPage(alice.page); + + await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' }); + await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + await new ChatMessagesPage(alice.page).waitForReady(); + + const bobSearch = new ServerSearchPage(bob.page); + + await bobSearch.searchInput.fill(serverName); + + await bob.page.locator('button', { hasText: serverName }) + .first() + .click(); + + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + await new ChatMessagesPage(bob.page).waitForReady(); + const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first(); + + await expect(bobRoomCard).toBeVisible({ timeout: 20_000 }); + + const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid'); + const bobUserId = bobUserCardTestId?.replace('room-user-card-', ''); + + if (!bobUserId) { + throw new Error('Expected Bob room user card to expose a stable test id.'); + } + + return { + alice, + bob, + bobUserId, + aliceSearch + }; +} + +async function registerUser(page: Page, username: string, displayName: string): Promise { + const registerPage = new RegisterPage(page); + + await registerPage.goto(); + await registerPage.register(username, displayName, 'TestPass123!'); + await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); +} + +function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36) + .slice(2, 8)}`; +} diff --git a/e2e/tests/settings/connectivity-warning.spec.ts b/e2e/tests/settings/connectivity-warning.spec.ts index cea0f04..3233d72 100644 --- a/e2e/tests/settings/connectivity-warning.spec.ts +++ b/e2e/tests/settings/connectivity-warning.spec.ts @@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); - await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Bob', async () => { @@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); - await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Charlie', async () => { @@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => { await register.goto(); await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); - await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); }); // ── Create server and have everyone join ── diff --git a/e2e/tests/settings/ice-server-settings.spec.ts b/e2e/tests/settings/ice-server-settings.spec.ts index e13b4c7..23b4d50 100644 --- a/e2e/tests/settings/ice-server-settings.spec.ts +++ b/e2e/tests/settings/ice-server-settings.spec.ts @@ -9,7 +9,7 @@ test.describe('ICE server settings', () => { await register.goto(); await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); - await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); await page.getByTitle('Settings').click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); await page.getByRole('button', { name: 'Network' }).click(); diff --git a/e2e/tests/settings/stun-turn-fallback.spec.ts b/e2e/tests/settings/stun-turn-fallback.spec.ts index fa2cc8e..d553d3f 100644 --- a/e2e/tests/settings/stun-turn-fallback.spec.ts +++ b/e2e/tests/settings/stun-turn-fallback.spec.ts @@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => { await register.goto(); await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); - await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Register Bob', async () => { @@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => { await register.goto(); await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); - await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 }); }); await test.step('Alice creates a server', async () => { diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index ba7337f..703e536 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise { } async function openSearchView(page: Page): Promise { - const searchInput = page.getByPlaceholder('Search servers...'); + const searchInput = page.getByPlaceholder('Search servers and users...'); if (await searchInput.isVisible().catch(() => false)) { return; @@ -567,7 +567,7 @@ async function openSearchView(page: Page): Promise { } async function joinRoomFromSearch(page: Page, roomName: string): Promise { - const searchInput = page.getByPlaceholder('Search servers...'); + const searchInput = page.getByPlaceholder('Search servers and users...'); await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts index c4e0c8b..1858247 100644 --- a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise { } async function openSearchView(page: Page): Promise { - const searchInput = page.getByPlaceholder('Search servers...'); + const searchInput = page.getByPlaceholder('Search servers and users...'); if (await searchInput.isVisible().catch(() => false)) { return; @@ -330,7 +330,7 @@ async function openSearchView(page: Page): Promise { } async function joinRoomFromSearch(page: Page, roomName: string): Promise { - const searchInput = page.getByPlaceholder('Search servers...'); + const searchInput = page.getByPlaceholder('Search servers and users...'); await expect(searchInput).toBeVisible({ timeout: 20_000 }); await searchInput.fill(roomName); diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 4387ff1..b3318a7 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -19,7 +19,8 @@ import { ServerAccessError, kickServerUser, ensureServerMembership, - unbanServerUser + unbanServerUser, + countServerMemberships } from '../services/server-access.service'; import { buildAppInviteUrl, @@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] { async function enrichServer(server: ServerPayload, sourceUrl?: string) { const owner = await getUserById(server.ownerId); + const userCount = await countServerMemberships(server.id); const { passwordHash, ...publicServer } = server; return { @@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) { hasPassword: server.hasPassword ?? !!passwordHash, ownerName: owner?.displayName, sourceUrl, - userCount: server.currentUsers + currentUsers: userCount, + userCount }; } diff --git a/server/src/services/server-access.service.ts b/server/src/services/server-access.service.ts index 20902fd..29fae00 100644 --- a/server/src/services/server-access.service.ts +++ b/server/src/services/server-access.service.ts @@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr return await getMembershipRepository().findOne({ where: { serverId, userId } }); } +export async function countServerMemberships(serverId: string): Promise { + return await getMembershipRepository().count({ where: { serverId } }); +} + export async function ensureServerMembership(serverId: string, userId: string): Promise { const repo = getMembershipRepository(); const now = Date.now(); diff --git a/toju-app/angular.json b/toju-app/angular.json index 8f71e07..f4ccea3 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -97,7 +97,7 @@ { "type": "initial", "maximumWarning": "2.2MB", - "maximumError": "2.35MB" + "maximumError": "2.36MB" }, { "type": "anyComponentStyle", diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index 65d399b..8a6b633 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -145,7 +145,7 @@ - } @if (!isThemeStudioFullscreen()) { + } @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) { } diff --git a/toju-app/src/app/app.routes.ts b/toju-app/src/app/app.routes.ts index 51dadc7..3c54a41 100644 --- a/toju-app/src/app/app.routes.ts +++ b/toju-app/src/app/app.routes.ts @@ -34,6 +34,16 @@ export const routes: Routes = [ loadComponent: () => import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent) }, + { + path: 'dm', + loadComponent: () => + import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent) + }, + { + path: 'dm/:conversationId', + loadComponent: () => + import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent) + }, { path: 'settings', loadComponent: () => diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index c75154a..7ddc03b 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -45,10 +45,7 @@ import { UsersActions } from './store/users/users.actions'; import { RoomsActions } from './store/rooms/rooms.actions'; import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { ROOM_URL_PATTERN } from './core/constants'; -import { - clearStoredCurrentUserId, - getStoredCurrentUserId -} from './core/storage/current-user-storage'; +import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage'; import { ThemeNodeDirective, ThemePickerOverlayComponent, @@ -102,6 +99,7 @@ export class App implements OnInit, OnDestroy { readonly themeStudioFullscreenComponent = signal | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly isDraggingThemeStudioControls = signal(false); + readonly currentRouteUrl = signal(this.getCurrentRouteUrl()); readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell')); readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail')); @@ -115,6 +113,7 @@ export class App implements OnInit, OnDestroy { return this.settingsModal.activePage() === 'theme' && this.settingsModal.themeStudioMinimized(); }); + readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm')); readonly desktopUpdateNoticeKey = computed(() => { const updateState = this.desktopUpdateState(); @@ -276,6 +275,8 @@ export class App implements OnInit, OnDestroy { this.router.events.subscribe((evt) => { if (evt instanceof NavigationEnd) { const url = evt.urlAfterRedirects || evt.url; + + this.currentRouteUrl.set(url); const roomMatch = url.match(ROOM_URL_PATTERN); const currentRoomId = roomMatch ? roomMatch[1] : null; diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index 061576b..3457c92 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -12,6 +12,7 @@ infrastructure adapters and UI. | **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | +| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` | @@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders: - [access-control/README.md](access-control/README.md) - [authentication/README.md](authentication/README.md) - [chat/README.md](chat/README.md) +- [direct-message/README.md](direct-message/README.md) - [notifications/README.md](notifications/README.md) - [profile-avatar/README.md](profile-avatar/README.md) - [screen-share/README.md](screen-share/README.md) diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html index ce2cc57..324d41a 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.html @@ -172,6 +172,7 @@