user picker

This commit is contained in:
2026-03-25 21:17:55 +01:00
parent 24bf3e38a7
commit 2fb6bd3783
6 changed files with 346 additions and 2 deletions

View File

@@ -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<UserProfile[]> {
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<UserProfile, 'id' | 'displayName'>): 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<UserProfile> & {
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<void> {
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<string, { id: string; displayName: string }>();
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<Pick<UserProfile, 'id' | 'displayName'>>;
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<string> {
const buffer = await blob.arrayBuffer();
let binary = '';

View File

@@ -220,6 +220,55 @@
<div class="col-lg-7">
<div class="panel p-4 h-100">
<section class="access-key-panel user-search-panel mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Find people</h3>
<p class="small text-secondary mb-0">Search approved accounts and add them to the chat peer list.</p>
</div>
<button
class="btn btn-sm btn-outline-light"
type="button"
[disabled]="loadingKnownUsers()"
(click)="reloadKnownUsers()"
>
Refresh
</button>
</div>
<div class="user-search-shell">
<input
class="form-control"
[(ngModel)]="userSearch"
placeholder="Search by display name or username"
/>
@if (knownUsersError()) {
<div class="alert alert-danger mt-3 mb-0">{{ knownUsersError() }}</div>
} @else if (loadingKnownUsers()) {
<div class="empty-state p-3 text-center text-secondary mt-3">Loading users...</div>
} @else if (filteredKnownUsers().length === 0) {
<div class="empty-state p-3 text-center text-secondary mt-3">No matching users found.</div>
} @else {
<div class="user-search-results mt-3">
@for (user of filteredKnownUsers(); track user.id) {
<button class="user-search-result" type="button" (click)="addKnownPeer(user)">
<span class="user-search-result-copy">
<span class="fw-semibold">{{ user.displayName }}</span>
<span class="small text-secondary">@{{ user.username }}</span>
</span>
<span class="user-search-result-action">Add</span>
</button>
}
</div>
}
</div>
@if (knownUsersNotice()) {
<div class="small text-secondary mt-3">{{ knownUsersNotice() }}</div>
}
</section>
<section class="access-key-panel">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>

View File

@@ -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);

View File

@@ -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<AdminUserSummary[]>([]);
readonly knownUsers = signal<UserProfile[]>([]);
readonly loadingKnownUsers = signal(false);
readonly knownUsersError = signal<string | null>(null);
readonly knownUsersNotice = signal<string | null>(null);
readonly loadingAdminUsers = signal(false);
readonly deletingUserId = signal<string | null>(null);
readonly adminUsersError = signal<string | null>(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<void> {
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<void> {
this.loadingAdminUsers.set(true);
this.adminUsersError.set(null);