Refactor 4 with bugfixes
This commit is contained in:
@@ -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 || ''));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user