Refactor 4 with bugfixes

This commit is contained in:
2026-03-04 03:56:23 +01:00
parent be91b6dfe8
commit 0ed9ca93d3
51 changed files with 1552 additions and 996 deletions

View File

@@ -5,6 +5,7 @@ import {
signal,
effect
} from '@angular/core';
import { take } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service';
import { Store } from '@ngrx/store';
@@ -983,12 +984,10 @@ export class AttachmentService {
/** Resolve the display name of the current room via the NgRx store. */
private resolveCurrentRoomName(): Promise<string> {
return new Promise<string>((resolve) => {
const subscription = this.ngrxStore
this.ngrxStore
.select(selectCurrentRoomName)
.subscribe((name) => {
resolve(name || '');
subscription.unsubscribe();
});
.pipe(take(1))
.subscribe((name) => resolve(name || ''));
});
}

View File

@@ -516,6 +516,10 @@
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<textarea
#messageInputRef

View File

@@ -187,6 +187,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private toolbarHovering = false;
inlineCodeToken = '`';
dragActive = signal(false);
private dragDepth = 0;
inputHovered = signal(false);
ctrlHeld = signal(false);
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
@@ -766,49 +767,140 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
/** Handle drag-enter to activate the drop zone overlay. */
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
if (!this.hasPotentialFilePayload(evt))
return;
evt.preventDefault();
evt.stopPropagation();
this.dragDepth++;
this.dragActive.set(true);
}
/** Keep the drop zone active while dragging over. */
onDragOver(evt: DragEvent): void {
if (!this.hasPotentialFilePayload(evt))
return;
evt.preventDefault();
evt.stopPropagation();
if (evt.dataTransfer) {
evt.dataTransfer.dropEffect = 'copy';
}
this.dragActive.set(true);
}
/** Deactivate the drop zone when dragging leaves. */
onDragLeave(evt: DragEvent): void {
if (!this.dragActive())
return;
evt.preventDefault();
this.dragActive.set(false);
evt.stopPropagation();
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0) {
this.dragActive.set(false);
}
}
/** Handle dropped files, adding them to the pending upload queue. */
onDrop(evt: DragEvent): void {
evt.preventDefault();
const files: File[] = [];
const items = evt.dataTransfer?.items;
evt.stopPropagation();
this.dragDepth = 0;
const droppedFiles = this.extractDroppedFiles(evt);
if (droppedFiles.length === 0) {
this.dragActive.set(false);
return;
}
this.pendingFiles.push(...droppedFiles);
// Keep toolbar visible so user sees options
this.toolbarVisible.set(true);
this.dragActive.set(false);
}
private hasPotentialFilePayload(evt: DragEvent): boolean {
const dataTransfer = evt.dataTransfer;
if (!dataTransfer)
return false;
if (dataTransfer.files?.length)
return true;
const items = dataTransfer.items;
if (items?.length) {
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
if (items[itemIndex].kind === 'file') {
return true;
}
}
}
const types = evt.dataTransfer?.types;
if (!types || types.length === 0)
// Some desktop-to-browser drags expose no types until drop.
return true;
for (let typeIndex = 0; typeIndex < types.length; typeIndex++) {
const type = types[typeIndex];
if (
type === 'Files' ||
type === 'application/x-moz-file' ||
type === 'public.file-url' ||
type === 'text/uri-list'
) {
return true;
}
}
return false;
}
private extractDroppedFiles(evt: DragEvent): File[] {
const droppedFiles: File[] = [];
const items = evt.dataTransfer?.items ?? null;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const item = items[itemIndex];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file)
files.push(file);
droppedFiles.push(file);
}
}
} else if (evt.dataTransfer?.files?.length) {
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
files.push(evt.dataTransfer.files[i]);
}
}
files.forEach((file) => this.pendingFiles.push(file));
// Keep toolbar visible so user sees options
this.toolbarVisible.set(true);
this.dragActive.set(false);
const files = evt.dataTransfer?.files;
if (!files?.length)
return droppedFiles;
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
const file = files[fileIndex];
const exists = droppedFiles.some((existing) =>
existing.name === file.name &&
existing.size === file.size &&
existing.type === file.type &&
existing.lastModified === file.lastModified
);
if (!exists) {
droppedFiles.push(file);
}
}
return droppedFiles;
}
/** Return all file attachments associated with a message. */

View File

@@ -205,7 +205,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
await this.webrtcService.setLocalStream(stream);
// Track local mic for voice-activity visualisation
const userId = this.currentUser()?.id;
// Use oderId||id to match the key used by the rooms-side-panel template.
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.trackLocalMic(userId, stream);
@@ -270,7 +271,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
// Untrack local mic from voice-activity visualisation
const userId = this.currentUser()?.id;
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);

View File

@@ -55,7 +55,7 @@ type MessageHandler = (
*/
function handleInventoryRequest(
event: any,
{ db, webrtc }: IncomingMessageContext
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
const { roomId, fromPeerId } = event;
@@ -66,7 +66,15 @@ function handleInventoryRequest(
(async () => {
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const items = await Promise.all(
messages.map((msg) => buildInventoryItem(msg, db))
messages.map((msg) => {
const inMemoryAttachmentCount = attachments.getForMessage(msg.id).length;
return buildInventoryItem(
msg,
db,
inMemoryAttachmentCount > 0 ? inMemoryAttachmentCount : undefined
);
})
);
items.sort((firstItem, secondItem) => firstItem.ts - secondItem.ts);
@@ -90,7 +98,7 @@ function handleInventoryRequest(
*/
function handleInventory(
event: any,
{ db, webrtc }: IncomingMessageContext
{ db, webrtc, attachments }: IncomingMessageContext
): Observable<Action> {
const { roomId, fromPeerId, items } = event;
@@ -100,7 +108,17 @@ function handleInventory(
return from(
(async () => {
const local = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const localMap = await buildLocalInventoryMap(local, db);
const inMemoryAttachmentCounts = new Map<string, number>();
for (const message of local) {
const count = attachments.getForMessage(message.id).length;
if (count > 0) {
inMemoryAttachmentCounts.set(message.id, count);
}
}
const localMap = await buildLocalInventoryMap(local, db, inMemoryAttachmentCounts);
const missing = findMissingIds(items, localMap);
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
@@ -204,7 +222,7 @@ async function processSyncBatch(
toUpsert.push(message);
}
if (event.attachments && event.fromPeerId) {
if (event.attachments && typeof event.attachments === 'object') {
requestMissingImages(event.attachments, attachments);
}

View File

@@ -73,33 +73,47 @@ export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
/** Builds a sync inventory item from a message and its reaction count. */
export async function buildInventoryItem(
msg: Message,
db: DatabaseService
db: DatabaseService,
attachmentCountOverride?: number
): Promise<InventoryItem> {
const reactions = await db.getReactionsForMessage(msg.id);
const attachments =
attachmentCountOverride === undefined
? await db.getAttachmentsForMessage(msg.id)
: [];
return { id: msg.id,
ts: getMessageTimestamp(msg),
rc: reactions.length };
rc: reactions.length,
ac: attachmentCountOverride ?? attachments.length };
}
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */
export async function buildLocalInventoryMap(
messages: Message[],
db: DatabaseService
): Promise<Map<string, { ts: number; rc: number }>> {
const map = new Map<string, { ts: number; rc: number }>();
db: DatabaseService,
attachmentCountOverrides?: ReadonlyMap<string, number>
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
const map = new Map<string, { ts: number; rc: number; ac: number }>();
await Promise.all(
messages.map(async (msg) => {
const reactions = await db.getReactionsForMessage(msg.id);
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
const attachments =
attachmentCountOverride === undefined
? await db.getAttachmentsForMessage(msg.id)
: [];
map.set(msg.id, { ts: getMessageTimestamp(msg),
rc: reactions.length });
rc: reactions.length,
ac: attachmentCountOverride ?? attachments.length });
})
);
@@ -108,8 +122,8 @@ export async function buildLocalInventoryMap(
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number }>
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
): string[] {
const missing: string[] = [];
@@ -119,7 +133,8 @@ export function findMissingIds(
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc)
(item.rc !== undefined && item.rc !== local.rc) ||
(item.ac !== undefined && item.ac !== local.ac)
) {
missing.push(item.id);
}

View File

@@ -32,7 +32,10 @@ import {
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { BanEntry } from '../../core/models';
import {
BanEntry,
User
} from '../../core/models';
@Injectable()
export class UsersEffects {
@@ -48,12 +51,22 @@ export class UsersEffects {
ofType(UsersActions.loadCurrentUser),
switchMap(() =>
from(this.db.getCurrentUser()).pipe(
map((user) => {
if (user) {
return UsersActions.loadCurrentUserSuccess({ user });
switchMap((user) => {
if (!user) {
return of(UsersActions.loadCurrentUserFailure({ error: 'No current user' }));
}
return UsersActions.loadCurrentUserFailure({ error: 'No current user' });
const sanitizedUser = this.clearStartupVoiceConnection(user);
if (sanitizedUser === user) {
return of(UsersActions.loadCurrentUserSuccess({ user }));
}
return from(this.db.updateUser(user.id, { voiceState: sanitizedUser.voiceState })).pipe(
map(() => UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })),
// If persistence fails, still load a sanitized in-memory user to keep UI correct.
catchError(() => of(UsersActions.loadCurrentUserSuccess({ user: sanitizedUser })))
);
}),
catchError((error) =>
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
@@ -63,6 +76,33 @@ export class UsersEffects {
)
);
private clearStartupVoiceConnection(user: User): User {
const voiceState = user.voiceState;
if (!voiceState)
return user;
const hasStaleConnectionState =
voiceState.isConnected ||
voiceState.isSpeaking ||
voiceState.roomId !== undefined ||
voiceState.serverId !== undefined;
if (!hasStaleConnectionState)
return user;
return {
...user,
voiceState: {
...voiceState,
isConnected: false,
isSpeaking: false,
roomId: undefined,
serverId: undefined
}
};
}
/** Loads all users associated with a specific room from the local database. */
loadRoomUsers$ = createEffect(() =>
this.actions$.pipe(