offline users and messages
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user