diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 5753689..bd3f6ca 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -125,6 +125,7 @@ function readDefaultServerUrl(): string { export class ChatSessionService { private static readonly messageDatabaseName = 'privatechat'; private static readonly messageStoreName = 'conversation_messages'; + private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers'; private static readonly messageRetentionLimit = 256; private static readonly sessionKeepaliveMs = 5 * 60 * 1000; private static readonly signalingHeartbeatMs = 25 * 1000; @@ -1119,6 +1120,82 @@ export class ChatSessionService { return response.users; } + async loadKnownUsers(): Promise { + const token = this.token(); + + if (!token) { + throw new Error('Authentication required.'); + } + + const response = await firstValueFrom( + this.http.get<{ users: UserProfile[] }>(`${this.serverUrl()}/api/users`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + return response.users + .filter((user): user is UserProfile => this.isDiscoverableUserProfile(user)) + .filter((user, index, users) => users.findIndex((candidate) => candidate.id === user.id) === index) + .sort((left, right) => + left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username), + ); + } + + rememberKnownPeer(peer: Pick): void { + const currentUserId = this.currentUser()?.id; + const peerId = peer.id.trim(); + const displayName = peer.displayName.trim(); + + if (!currentUserId || !peerId || peerId === currentUserId) { + return; + } + + const currentKnownPeers = this.readKnownPeersFromStorage(currentUserId); + const nextKnownPeers = this.normalizeKnownPeers([ + ...currentKnownPeers, + { id: peerId, displayName: displayName || peerId }, + ], currentUserId); + + if (JSON.stringify(nextKnownPeers) === JSON.stringify(currentKnownPeers)) { + return; + } + + this.writeKnownPeersToStorage(currentUserId, nextKnownPeers); + } + + private isDiscoverableUserProfile(value: unknown): value is UserProfile { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial & { + credentialId?: unknown; + label?: unknown; + aaguid?: unknown; + deviceType?: unknown; + backedUp?: unknown; + transports?: unknown; + }; + + if ( + typeof candidate.id !== 'string' + || !candidate.id.trim() + || typeof candidate.username !== 'string' + || !candidate.username.trim() + || typeof candidate.displayName !== 'string' + || !candidate.displayName.trim() + ) { + return false; + } + + return typeof candidate.credentialId === 'undefined' + && typeof candidate.label === 'undefined' + && typeof candidate.aaguid === 'undefined' + && typeof candidate.deviceType === 'undefined' + && typeof candidate.backedUp === 'undefined' + && typeof candidate.transports === 'undefined'; + } + async deleteUserAccount(userId: string): Promise { const token = this.token(); @@ -2487,6 +2564,69 @@ export class ChatSessionService { this.removeStorage('privatechat.user'); } + private readKnownPeersFromStorage(currentUserId: string): Array<{ id: string; displayName: string }> { + const storedValue = this.readStorage(`${ChatSessionService.knownPeersStoragePrefix}.${currentUserId}`); + + if (!storedValue) { + return []; + } + + try { + const parsedValue = JSON.parse(storedValue); + return Array.isArray(parsedValue) ? this.normalizeKnownPeers(parsedValue, currentUserId) : []; + } catch { + return []; + } + } + + private writeKnownPeersToStorage(currentUserId: string, peers: Array<{ id: string; displayName: string }>): void { + const storageKey = `${ChatSessionService.knownPeersStoragePrefix}.${currentUserId}`; + + if (peers.length === 0) { + this.removeStorage(storageKey); + return; + } + + this.writeStorage(storageKey, JSON.stringify(peers)); + } + + private normalizeKnownPeers(peers: unknown[], currentUserId: string): Array<{ id: string; displayName: string }> { + const peerMap = new Map(); + + for (const peer of peers) { + if (typeof peer === 'string') { + const id = peer.trim(); + + if (!id || id === currentUserId) { + continue; + } + + peerMap.set(id, { id, displayName: id }); + continue; + } + + if (!peer || typeof peer !== 'object') { + continue; + } + + const candidate = peer as Partial>; + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + + if (!id || id === currentUserId) { + continue; + } + + const displayName = typeof candidate.displayName === 'string' && candidate.displayName.trim() + ? candidate.displayName.trim() + : peerMap.get(id)?.displayName ?? id; + + peerMap.set(id, { id, displayName }); + } + + return Array.from(peerMap.values()) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + } + private async blobToBase64(blob: Blob): Promise { const buffer = await blob.arrayBuffer(); let binary = ''; diff --git a/client/src/app/home-page.component.html b/client/src/app/home-page.component.html index f78b48d..7c55a4e 100644 --- a/client/src/app/home-page.component.html +++ b/client/src/app/home-page.component.html @@ -220,6 +220,55 @@
+
+
+
+

Find people

+

Search approved accounts and add them to the chat peer list.

+
+ +
+ +
+ + + @if (knownUsersError()) { +
{{ knownUsersError() }}
+ } @else if (loadingKnownUsers()) { +
Loading users...
+ } @else if (filteredKnownUsers().length === 0) { +
No matching users found.
+ } @else { +
+ @for (user of filteredKnownUsers(); track user.id) { + + } +
+ } +
+ + @if (knownUsersNotice()) { +
{{ knownUsersNotice() }}
+ } +
+
diff --git a/client/src/app/home-page.component.scss b/client/src/app/home-page.component.scss index fac49b2..a264bb3 100644 --- a/client/src/app/home-page.component.scss +++ b/client/src/app/home-page.component.scss @@ -114,6 +114,58 @@ background: var(--panel-soft-background); } +.user-search-panel { + display: grid; + gap: 0.75rem; +} + +.user-search-shell { + display: grid; + gap: 0.75rem; +} + +.user-search-results { + display: grid; + gap: 0.65rem; +} + +.user-search-result { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.9rem 1rem; + border: 1px solid var(--surface-border-soft); + border-radius: 0.9rem; + color: var(--page-text); + background: var(--surface-background); + text-align: left; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.user-search-result:hover, +.user-search-result:focus-visible { + border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border)); + background: var(--surface-hover-background); + transform: translateY(-1px); +} + +.user-search-result-copy { + display: grid; + gap: 0.1rem; + min-width: 0; +} + +.user-search-result-action { + flex: 0 0 auto; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent-color); +} + .access-key-card { border-radius: 0.9rem; border: 1px solid var(--surface-border-soft); diff --git a/client/src/app/home-page.component.ts b/client/src/app/home-page.component.ts index 2a1e2c8..a28d294 100644 --- a/client/src/app/home-page.component.ts +++ b/client/src/app/home-page.component.ts @@ -1,10 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, effect, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { ChatSessionService } from './chat-session.service'; -import type { AdminUserSummary } from './models'; +import type { AdminUserSummary, UserProfile } from './models'; import { ThemeService } from './theme.service'; @Component({ @@ -24,10 +24,30 @@ export class HomePageComponent { username = ''; password = ''; accessKeyLabel = ''; + userSearch = ''; readonly adminUsers = signal([]); + readonly knownUsers = signal([]); + readonly loadingKnownUsers = signal(false); + readonly knownUsersError = signal(null); + readonly knownUsersNotice = signal(null); readonly loadingAdminUsers = signal(false); readonly deletingUserId = signal(null); readonly adminUsersError = signal(null); + readonly filteredKnownUsers = computed(() => { + const query = this.userSearch.trim().toLowerCase(); + const users = this.knownUsers(); + + if (!query) { + return users.slice(0, 8); + } + + return users + .filter((user) => + user.displayName.toLowerCase().includes(query) + || user.username.toLowerCase().includes(query), + ) + .slice(0, 8); + }); constructor(readonly session: ChatSessionService) { this.serverUrl = session.serverUrl(); @@ -45,6 +65,21 @@ export class HomePageComponent { }); } + effect(() => { + const currentUser = this.session.currentUser(); + + if (!currentUser) { + this.knownUsers.set([]); + this.loadingKnownUsers.set(false); + this.knownUsersError.set(null); + this.knownUsersNotice.set(null); + this.userSearch = ''; + return; + } + + void this.reloadKnownUsers(); + }); + effect(() => { const currentUser = this.session.currentUser(); @@ -98,6 +133,27 @@ export class HomePageComponent { this.accessKeyLabel = ''; } + async reloadKnownUsers(): Promise { + this.loadingKnownUsers.set(true); + this.knownUsersError.set(null); + + try { + this.knownUsers.set(await this.session.loadKnownUsers()); + } catch (error) { + this.knownUsersError.set( + error instanceof Error ? error.message : 'Could not load users.', + ); + } finally { + this.loadingKnownUsers.set(false); + } + } + + addKnownPeer(user: UserProfile): void { + this.session.rememberKnownPeer(user); + this.knownUsersNotice.set(`${user.displayName} was added to your chat peer list.`); + this.userSearch = user.displayName; + } + async reloadAdminUsers(): Promise { this.loadingAdminUsers.set(true); this.adminUsersError.set(null); diff --git a/server/dist/index.js b/server/dist/index.js index fcc7a37..47d246e 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -472,6 +472,15 @@ app.get('/api/auth/session', async (request, reply) => { messageEncryptionKey: authContext.user.messageEncryptionKey, }; }); +app.get('/api/users', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + if (!authContext) { + return; + } + return { + users: listDiscoverableUsers(authContext.user.id), + }; +}); app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { @@ -981,6 +990,17 @@ function listAdminUsers() { approvedAt: row.approved_at, })); } +function listDiscoverableUsers(currentUserId) { + const rows = selectAllUsersStatement.all(); + return rows + .filter((row) => row.is_active === 1 && row.id !== currentUserId) + .map((row) => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + })) + .sort((left, right) => left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username)); +} function approveUser(userId) { const approvedAt = new Date().toISOString(); const result = approveUserStatement.run(approvedAt, userId); diff --git a/server/src/index.ts b/server/src/index.ts index 41dd8df..c73a00c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -806,6 +806,18 @@ app.get('/api/auth/session', async (request, reply) => { }; }); +app.get('/api/users', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + + if (!authContext) { + return; + } + + return { + users: listDiscoverableUsers(authContext.user.id), + }; +}); + app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { const authContext = await authenticateRequest(request, reply); @@ -1497,6 +1509,21 @@ function listAdminUsers(): AdminUserSummary[] { })); } +function listDiscoverableUsers(currentUserId: string): PublicUser[] { + const rows = selectAllUsersStatement.all() as DatabaseUserRow[]; + + return rows + .filter((row) => row.is_active === 1 && row.id !== currentUserId) + .map((row) => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + })) + .sort((left, right) => + left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username), + ); +} + function approveUser(userId: string): UserRecord | null { const approvedAt = new Date().toISOString(); const result = approveUserStatement.run(approvedAt, userId);