offline users and messages

This commit is contained in:
2026-03-25 20:09:36 +01:00
parent fd888c9ed1
commit f13c04e809
10 changed files with 792 additions and 154 deletions

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, untracked, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -9,6 +9,13 @@ import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
type KnownPeerSummary = {
id: string;
displayName: string;
};
type DropdownPeerSummary = PeerSummary & { knownOnly: boolean };
@Component({
selector: 'app-chat-page',
imports: [
@@ -22,6 +29,7 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
styleUrl: './chat-page.component.scss',
})
export class ChatPageComponent implements OnDestroy {
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
@@ -47,6 +55,7 @@ export class ChatPageComponent implements OnDestroy {
private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0;
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
private lastAutoConnectSnapshot: { peerId: string; hasLivePeer: boolean } | null = null;
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
@@ -73,6 +82,7 @@ export class ChatPageComponent implements OnDestroy {
readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false);
readonly isTranscribingDictation = signal(false);
readonly knownPeers = signal<KnownPeerSummary[]>([]);
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
@@ -102,7 +112,32 @@ export class ChatPageComponent implements OnDestroy {
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null);
readonly dropdownPeers = computed<DropdownPeerSummary[]>(() => {
const connectedPeers = this.session.peers();
const connectedPeerIds = new Set(connectedPeers.map((peer) => peer.id));
const knownOnlyPeers = this.knownPeers()
.filter((peer) => !connectedPeerIds.has(peer.id))
.sort((left, right) => left.displayName.localeCompare(right.displayName))
.map<DropdownPeerSummary>((peer) => ({
id: peer.id,
username: peer.id,
displayName: peer.displayName,
connectionState: 'disconnected',
channelState: 'closed',
knownOnly: true,
}));
return [
...connectedPeers.map<DropdownPeerSummary>((peer) => ({ ...peer, knownOnly: false })),
...knownOnlyPeers,
];
});
readonly displayedPeer = computed<DropdownPeerSummary | null>(() => {
const selectedPeerId = this.peerId();
const peers = this.dropdownPeers();
return (selectedPeerId ? peers.find((peer) => peer.id === selectedPeerId) ?? null : null) ?? peers[0] ?? null;
});
readonly currentUser = computed(() => this.session.currentUser());
readonly callModalPeerId = computed(() =>
this.session.activeVoiceCallPeerId()
@@ -118,7 +153,7 @@ export class ChatPageComponent implements OnDestroy {
readonly callChoicePeer = computed(() => {
const peerId = this.callChoicePeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
return peerId ? this.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly conversation = computed(() =>
this.session
@@ -240,13 +275,61 @@ export class ChatPageComponent implements OnDestroy {
}
effect(() => {
const peerId = this.peerId();
const currentUserId = this.currentUser()?.id ?? null;
this.knownPeers.set(this.readKnownPeers(currentUserId));
});
if (!peerId) {
effect(() => {
const connectedPeers = this.session.peers();
if (connectedPeers.length === 0) {
return;
}
this.mergeKnownPeers(
connectedPeers.map((peer) => ({ id: peer.id, displayName: peer.displayName })),
);
});
effect(() => {
const currentUserId = this.currentUser()?.id ?? null;
const knownPeersFromMessages = this.session.messages()
.filter((entry) => entry.direction !== 'system' && entry.kind !== 'system')
.map((entry) => ({
id: entry.peerId,
displayName: entry.direction === 'incoming' && entry.authorLabel !== 'You'
? entry.authorLabel
: this.findKnownPeerDisplayName(entry.peerId) ?? entry.peerId,
}));
if (!currentUserId || knownPeersFromMessages.length === 0) {
return;
}
this.mergeKnownPeers(knownPeersFromMessages);
});
effect(() => {
const peerId = this.peerId();
const hasLivePeer = !!this.peer();
const previousSnapshot = this.lastAutoConnectSnapshot;
if (!peerId) {
this.lastAutoConnectSnapshot = null;
return;
}
this.lastAutoConnectSnapshot = { peerId, hasLivePeer };
this.session.selectPeer(peerId);
if (!hasLivePeer) {
return;
}
if (previousSnapshot?.peerId === peerId && previousSnapshot.hasLivePeer) {
return;
}
void this.session.connectToPeer(peerId);
});
@@ -291,7 +374,7 @@ export class ChatPageComponent implements OnDestroy {
async ensureConnection(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
if (!peerId || !this.peer()) {
return;
}
@@ -593,6 +676,7 @@ export class ChatPageComponent implements OnDestroy {
async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation();
await this.session.deleteConversation(peerId);
this.removeKnownPeer(peerId);
}
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
@@ -619,7 +703,7 @@ export class ChatPageComponent implements OnDestroy {
}
togglePeerDropdown(): void {
if (this.session.peers().length === 0) {
if (this.dropdownPeers().length === 0) {
return;
}
@@ -739,6 +823,10 @@ export class ChatPageComponent implements OnDestroy {
return this.session.unreadPeerIds().includes(peerId);
}
isPendingOutgoingEntry(entry: ChatEntry): boolean {
return entry.direction === 'outgoing' && entry.deliveryState === 'pending';
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
@@ -752,7 +840,7 @@ export class ChatPageComponent implements OnDestroy {
}
canReconnectWebRtc(): boolean {
return !!this.peerId() && this.indicatorTone(this.webRtcState()) !== 'ok';
return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok';
}
async switchPeer(peerId: string): Promise<void> {
@@ -767,10 +855,159 @@ export class ChatPageComponent implements OnDestroy {
this.conversationModalOpen.set(false);
this.peerDropdownOpen.set(false);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
private mergeKnownPeers(peers: Array<{ id: string; displayName?: string }>): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId) {
return;
}
const currentKnownPeers = untracked(this.knownPeers);
const nextPeers = this.normalizeKnownPeers([
...currentKnownPeers,
...peers,
], currentUserId);
if (this.areKnownPeersEqual(nextPeers, currentKnownPeers)) {
return;
}
this.knownPeers.set(nextPeers);
this.writeKnownPeers(currentUserId, nextPeers);
}
private removeKnownPeer(peerId: string): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId) {
return;
}
const currentKnownPeers = untracked(this.knownPeers);
const nextPeers = currentKnownPeers.filter((knownPeer) => knownPeer.id !== peerId);
if (nextPeers.length === currentKnownPeers.length) {
return;
}
this.knownPeers.set(nextPeers);
this.writeKnownPeers(currentUserId, nextPeers);
}
private readKnownPeers(currentUserId: string | null): KnownPeerSummary[] {
if (!currentUserId) {
return [];
}
const storedValue = this.readStorage(this.knownPeersStorageKey(currentUserId));
if (!storedValue) {
return [];
}
try {
const parsedValue = JSON.parse(storedValue);
if (!Array.isArray(parsedValue)) {
return [];
}
return this.normalizeKnownPeers(parsedValue, currentUserId);
} catch {
return [];
}
}
private writeKnownPeers(currentUserId: string, peers: KnownPeerSummary[]): void {
const storageKey = this.knownPeersStorageKey(currentUserId);
if (peers.length === 0) {
this.removeStorage(storageKey);
return;
}
this.writeStorage(storageKey, JSON.stringify(peers));
}
private normalizeKnownPeers(peers: unknown[], currentUserId: string): KnownPeerSummary[] {
const peerMap = new Map<string, KnownPeerSummary>();
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<KnownPeerSummary>;
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 areKnownPeersEqual(left: KnownPeerSummary[], right: KnownPeerSummary[]): boolean {
return left.length === right.length
&& left.every((peer, index) =>
peer.id === right[index]?.id && peer.displayName === right[index]?.displayName,
);
}
private findKnownPeerDisplayName(peerId: string): string | null {
return untracked(this.knownPeers).find((peer) => peer.id === peerId)?.displayName ?? null;
}
private knownPeersStorageKey(currentUserId: string): string {
return `${ChatPageComponent.knownPeersStoragePrefix}.${currentUserId}`;
}
private readStorage(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
private writeStorage(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Ignore storage errors in private browsing modes.
}
}
private removeStorage(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Ignore storage errors in private browsing modes.
}
}
private stopVoiceRecording(discard: boolean): void {
const recorder = this.voiceRecorder;