user picker
This commit is contained in:
@@ -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 = '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
20
server/dist/index.js
vendored
20
server/dist/index.js
vendored
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user