fix typing indicator on wrong server
Some checks failed
Queue Release Build / build-linux (push) Blocked by required conditions
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled

This commit is contained in:
2026-03-18 22:10:11 +01:00
parent 141de64767
commit 1cdd1c5d2b
11 changed files with 431 additions and 108 deletions

View File

@@ -5,6 +5,10 @@ import {
signal,
effect
} from '@angular/core';
import {
NavigationEnd,
Router
} from '@angular/router';
import { take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service';
@@ -12,6 +16,7 @@ import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
import { DatabaseService } from './database.service';
import { recordDebugNetworkFileChunk } from './debug-network-metrics.service';
import { ROOM_URL_PATTERN } from '../constants';
import type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta,
@@ -145,9 +150,14 @@ export class AttachmentService {
private readonly webrtc = inject(WebRTCService);
private readonly ngrxStore = inject(Store);
private readonly database = inject(DatabaseService);
private readonly router = inject(Router);
/** Primary index: `messageId → Attachment[]`. */
private attachmentsByMessage = new Map<string, Attachment[]>();
/** Runtime cache of `messageId → roomId` for attachment gating. */
private messageRoomIds = new Map<string, string>();
/** Room currently being watched in the router, or `null` outside room routes. */
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
/** Incremented on every mutation so signal consumers re-render. */
updated = signal<number>(0);
@@ -190,6 +200,24 @@ export class AttachmentService {
this.initFromDatabase();
}
});
this.router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) {
return;
}
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
}
private getElectronApi(): AttachmentElectronApi | undefined {
@@ -201,6 +229,44 @@ export class AttachmentService {
return this.attachmentsByMessage.get(messageId) ?? [];
}
/** Cache the room that owns a message so background downloads can be gated by the watched server. */
rememberMessageRoom(messageId: string, roomId: string): void {
if (!messageId || !roomId)
return;
this.messageRoomIds.set(messageId, roomId);
}
/** Queue best-effort auto-download checks for a message's eligible attachments. */
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
}
/** Auto-request eligible missing attachments for the currently watched room. */
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId))
return;
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.attachmentsByMessage) {
const attachmentRoomId = await this.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
/** Remove every attachment associated with a message. */
async deleteForMessage(messageId: string): Promise<void> {
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
@@ -219,6 +285,7 @@ export class AttachmentService {
}
this.attachmentsByMessage.delete(messageId);
this.messageRoomIds.delete(messageId);
this.clearMessageScopedState(messageId);
if (hadCachedAttachments) {
@@ -276,8 +343,15 @@ export class AttachmentService {
* @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer.
*/
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.rememberMessageRoom(messageId, roomId);
}
}
const newAttachments: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) {
@@ -306,6 +380,7 @@ export class AttachmentService {
for (const attachment of newAttachments) {
void this.persistAttachmentMeta(attachment);
this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id);
}
}
}
@@ -375,9 +450,9 @@ export class AttachmentService {
* message to all connected peers.
*
* 1. Each file is assigned a UUID.
* 2. A `file-announce` event is broadcast to peers.
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
* are immediately streamed as chunked base-64.
* 2. A `file-announce` event is broadcast to peers.
* 3. Peers watching the message's server can request any
* auto-download-eligible media on demand.
*
* @param messageId - ID of the parent message.
* @param files - Array of user-selected `File` objects.
@@ -437,10 +512,6 @@ export class AttachmentService {
this.webrtc.broadcastMessage(fileAnnounceEvent);
// Auto-stream small inline-preview media
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
await this.streamFileToPeers(messageId, fileId, file);
}
}
const existingList = this.attachmentsByMessage.get(messageId) ?? [];
@@ -482,6 +553,7 @@ export class AttachmentService {
this.attachmentsByMessage.set(messageId, list);
this.touch();
void this.persistAttachmentMeta(attachment);
this.queueAutoDownloadsForMessage(messageId, attachment.id);
}
/**
@@ -772,6 +844,38 @@ export class AttachmentService {
return `${messageId}:${fileId}`;
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId)
return;
const roomId = await this.resolveMessageRoomId(messageId);
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
return;
}
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
for (const attachment of attachments) {
if (attachmentId && attachment.id !== attachmentId)
continue;
if (!this.shouldAutoRequestWhenWatched(attachment))
continue;
if (attachment.available)
continue;
if ((attachment.receivedBytes ?? 0) > 0)
continue;
if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id)))
continue;
this.requestFromAnyPeer(messageId, attachment);
}
}
private clearMessageScopedState(messageId: string): void {
const scopedPrefix = `${messageId}:`;
@@ -867,6 +971,12 @@ export class AttachmentService {
attachment.mime.startsWith('audio/');
}
/** Auto-download only the assets that already supported eager loading when watched. */
private shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
return attachment.isImage ||
(this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
}
/** Check whether a completed download should be cached on disk. */
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
@@ -1167,6 +1277,38 @@ export class AttachmentService {
} catch { /* load is best-effort */ }
}
private extractWatchedRoomId(url: string): string | null {
const roomMatch = url.match(ROOM_URL_PATTERN);
return roomMatch ? roomMatch[1] : null;
}
private isRoomWatched(roomId: string | null | undefined): boolean {
return !!roomId && roomId === this.watchedRoomId;
}
private async resolveMessageRoomId(messageId: string): Promise<string | null> {
const cachedRoomId = this.messageRoomIds.get(messageId);
if (cachedRoomId)
return cachedRoomId;
if (!this.database.isReady())
return null;
try {
const message = await this.database.getMessageById(messageId);
if (!message?.roomId)
return null;
this.rememberMessageRoom(messageId, message.roomId);
return message.roomId;
} catch {
return null;
}
}
/** One-time migration from localStorage to the database. */
private async migrateFromLocalStorage(): Promise<void> {
try {