5 Commits

Author SHA1 Message Date
Myx
232a9ea8ea test: Ensure tests work after latest changes
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-windows (push) Successful in 25m4s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
2026-05-19 00:52:28 +02:00
Myx
54e8b9a5e4 feat: Update how messages load and sync, allow plugins to import messages
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
2026-05-18 23:21:09 +02:00
Myx
94428ed170 fix: Mobile style fixes and other small ui fixes 2026-05-18 23:20:32 +02:00
Myx
afb64520ed perf: server navigation 2026-05-18 19:38:08 +02:00
Myx
0152ed9dd2 fix: memory leak hunting and reconnecting on data error 2026-05-18 19:37:30 +02:00
65 changed files with 1807 additions and 484 deletions

View File

@@ -19,11 +19,11 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. | | `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. | | `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. | | `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. | | `messages.sync` | `messages.sync()`, `messages.import()`, `attachments.import()` | Syncs message arrays, imports historical messages locally, or imports files for those messages. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. | | `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. | | `channels.manage` | `channels.addTextChannel()`, `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. | | `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. | | `server.manage` | `server.updateIcon()`, `server.updatePermissions()`, `server.updateSettings()` | Updates server icon, permissions, or settings. `server.updateIcon()` resolves when the local icon update has been persisted or rejects if the current user is not allowed to manage the server icon. |
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. | | `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. | | `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. | | `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |

View File

@@ -7,20 +7,36 @@ import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) { export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload; const { roomId, limit = 100, offset = 0, channelId, beforeTimestamp } = query.payload;
const currentUserId = await getCurrentUserScope(dataSource); const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) { if (!currentUserId) {
return []; return [];
} }
const rows = await repo.find({ const rowsQuery = repo.createQueryBuilder('message')
where: { roomId, ownerUserId: currentUserId }, .where('message.roomId = :roomId', { roomId })
order: { timestamp: 'ASC' }, .andWhere('message.ownerUserId = :currentUserId', { currentUserId })
take: limit, .orderBy('message.timestamp', 'DESC')
skip: offset .take(limit)
}); .skip(offset);
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? [])); if (channelId === 'general') {
rowsQuery.andWhere('(message.channelId = :channelId OR message.channelId IS NULL OR message.channelId = :emptyChannelId)', {
channelId,
emptyChannelId: ''
});
} else if (channelId) {
rowsQuery.andWhere('message.channelId = :channelId', { channelId });
}
if (typeof beforeTimestamp === 'number') {
rowsQuery.andWhere('message.timestamp < :beforeTimestamp', { beforeTimestamp });
}
const rows = await rowsQuery.getMany();
const chronologicalRows = [...rows].reverse();
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, chronologicalRows.map((row) => row.id));
return chronologicalRows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
} }

View File

@@ -230,7 +230,16 @@ export type Command =
| SaveMetaCommand | SaveMetaCommand
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery {
type: typeof QueryType.GetMessages;
payload: {
roomId: string;
limit?: number;
offset?: number;
channelId?: string;
beforeTimestamp?: number;
};
}
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } } export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }

View File

@@ -41,7 +41,14 @@ const RETRYABLE_SAVE_ERROR_CODES = new Set([
'EBUSY' 'EBUSY'
]); ]);
let saveQueue: Promise<void> = Promise.resolve(); interface PendingSaveWaiter {
reject: (error: unknown) => void;
resolve: () => void;
}
let pendingSaveSnapshot: Buffer | null = null;
let pendingSaveWaiters: PendingSaveWaiter[] = [];
let saveInProgress = false;
function wait(ms: number): Promise<void> { function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -146,16 +153,51 @@ async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
} }
} }
function settleSaveWaiters(waiters: PendingSaveWaiter[], error?: unknown): void {
for (const waiter of waiters) {
if (error === undefined) {
waiter.resolve();
} else {
waiter.reject(error);
}
}
}
async function drainDatabaseSaveQueue(): Promise<void> {
if (saveInProgress) {
return;
}
saveInProgress = true;
try {
while (pendingSaveSnapshot) {
const snapshot = pendingSaveSnapshot;
const waiters = pendingSaveWaiters;
pendingSaveSnapshot = null;
pendingSaveWaiters = [];
try {
await writeDatabaseSnapshot(snapshot);
settleSaveWaiters(waiters);
} catch (error) {
settleSaveWaiters(waiters, error);
}
}
} finally {
saveInProgress = false;
}
}
async function atomicSave(data: Uint8Array): Promise<void> { async function atomicSave(data: Uint8Array): Promise<void> {
const snapshot = Buffer.from(data); const snapshot = Buffer.from(data);
const saveTask = saveQueue.then(
() => writeDatabaseSnapshot(snapshot),
() => writeDatabaseSnapshot(snapshot)
);
saveQueue = saveTask.catch(() => {}); return new Promise<void>((resolve, reject) => {
pendingSaveSnapshot = snapshot;
return saveTask; pendingSaveWaiters.push({ resolve, reject });
void drainDatabaseSaveQueue();
});
} }
export async function initializeDatabase(): Promise<void> { export async function initializeDatabase(): Promise<void> {

View File

@@ -62,6 +62,9 @@ import { listRunningProcessNames } from '../process-list';
import { detectActiveGame } from '../game-detection'; import { detectActiveGame } from '../game-detection';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
const activeDesktopNotifications = new Set<Notification>();
const desktopNotificationCleanups = new Map<Notification, () => void>();
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
'x-special/gnome-copied-files', 'x-special/gnome-copied-files',
'text/uri-list', 'text/uri-list',
@@ -399,9 +402,16 @@ export function setupSystemHandlers(): void {
icon: getWindowIconPath(), icon: getWindowIconPath(),
silent: true silent: true
}); });
const cleanup = () => {
notification.on('click', () => { notification.removeListener('click', handleClick);
notification.removeListener('close', cleanup);
notification.removeListener('failed', cleanup);
activeDesktopNotifications.delete(notification);
desktopNotificationCleanups.delete(notification);
};
const handleClick = () => {
if (!mainWindow) { if (!mainWindow) {
cleanup();
return; return;
} }
@@ -414,7 +424,26 @@ export function setupSystemHandlers(): void {
} }
mainWindow.focus(); mainWindow.focus();
}); cleanup();
notification.close();
};
notification.on('click', handleClick);
notification.once('close', cleanup);
notification.once('failed', cleanup);
activeDesktopNotifications.add(notification);
desktopNotificationCleanups.set(notification, cleanup);
while (activeDesktopNotifications.size > MAX_ACTIVE_DESKTOP_NOTIFICATIONS) {
const oldestNotification = activeDesktopNotifications.values().next().value;
if (!oldestNotification) {
break;
}
desktopNotificationCleanups.get(oldestNotification)?.();
oldestNotification.close();
}
notification.show(); notification.show();
} catch { } catch {

Binary file not shown.

View File

@@ -93,6 +93,16 @@
</main> </main>
</div> </div>
@if (isMobile() && directCalls.mobileOverlaySession(); as call) {
<div class="absolute inset-0 z-[70]">
<app-private-call
class="block h-full w-full"
[callIdInput]="call.callId"
[overlayMode]="true"
/>
</div>
}
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
<div <div
#themeStudioControlsRef #themeStudioControlsRef

View File

@@ -38,6 +38,7 @@ import { GameActivityService } from './domains/game-activity';
import { PluginBootstrapService } from './domains/plugins'; import { PluginBootstrapService } from './domains/plugins';
import { DirectCallService } from './domains/direct-call'; import { DirectCallService } from './domains/direct-call';
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component'; import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
import { PrivateCallComponent } from './features/direct-call/private-call.component';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -70,6 +71,7 @@ import {
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareSourcePickerComponent, ScreenShareSourcePickerComponent,
NativeContextMenuComponent, NativeContextMenuComponent,
PrivateCallComponent,
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent ThemePickerOverlayComponent
], ],
@@ -265,11 +267,22 @@ export class App implements OnInit, OnDestroy {
if (!currentUserId) { if (!currentUserId) {
if (!this.isPublicRoute(currentUrl)) { if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], { // On mobile, new/unauthenticated visitors landing on the app root or
queryParams: { // /search should stay on /search (which already exposes a login CTA).
returnUrl: currentUrl // The login form has no mobile chrome / back button, so dropping new
} // users straight onto it leaves them with no way to navigate away.
}).catch(() => {}); const currentPath = this.getRoutePath(currentUrl);
const isSearchLanding = currentPath === '/' || currentPath === '/search';
if (this.isMobile() && isSearchLanding) {
this.router.navigate(['/search'], { replaceUrl: true }).catch(() => {});
} else {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: currentUrl
}
}).catch(() => {});
}
} }
} else { } else {
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());

View File

@@ -35,6 +35,7 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false; private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
constructor() { constructor() {
effect(() => { effect(() => {
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
} }
async requestAutoDownloadsForRoom(roomId: string): Promise<void> { async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId)) if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
return; return;
if (this.database.isReady()) { const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) { if (activeRequest) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId); return activeRequest;
await this.requestAutoDownloadsForMessage(message.id);
}
return;
} }
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
this.autoDownloadRequestsByRoom.delete(roomId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
} }
} });
this.autoDownloadRequestsByRoom.set(roomId, request);
return request;
} }
async deleteForMessage(messageId: string): Promise<void> { async deleteForMessage(messageId: string): Promise<void> {
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
} }
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> { private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId) if (!messageId)
return; return;

View File

@@ -1,5 +1,11 @@
/** Maximum number of recent messages to include in sync inventories. */ /** Maximum number of messages to include in sync inventories.
export const INVENTORY_LIMIT = 1000; *
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
* chunked at `CHUNK_SIZE`, so peers converge on the full history regardless
* of how lopsided their message counts are. The constant remains as a safety
* ceiling for pathological rooms.
*/
export const INVENTORY_LIMIT = 1_000_000;
/** Number of messages per chunk for inventory / batch transfers. */ /** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200; export const CHUNK_SIZE = 200;
@@ -14,7 +20,7 @@ export const SYNC_POLL_SLOW_MS = 900_000;
export const SYNC_TIMEOUT_MS = 5_000; export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */ /** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000; export const FULL_SYNC_LIMIT = 1_000_000;
/** Inventory item representing a message's sync state. */ /** Inventory item representing a message's sync state. */
export interface InventoryItem { export interface InventoryItem {

View File

@@ -11,6 +11,8 @@
[isAdmin]="isAdmin()" [isAdmin]="isAdmin()"
[bottomPadding]="composerBottomPadding()" [bottomPadding]="composerBottomPadding()"
[conversationKey]="conversationKey()" [conversationKey]="conversationKey()"
[loadingOlder]="loadingOlder()"
[conversationExhausted]="conversationExhausted()"
(replyRequested)="setReplyTo($event)" (replyRequested)="setReplyTo($event)"
(deleteRequested)="handleDeleteRequested($event)" (deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)" (editSaved)="handleEditSaved($event)"
@@ -20,6 +22,7 @@
(imageOpened)="openLightbox($event)" (imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)" (imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)" (embedRemoved)="handleEmbedRemoved($event)"
(loadOlderRequested)="handleLoadOlderRequested($event)"
/> />
<div <div

View File

@@ -8,6 +8,8 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
@@ -18,7 +20,9 @@ import { KlipyGif, KlipyService } from '../../application/services/klipy.service
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
selectConversationExhausted,
selectMessagesLoading, selectMessagesLoading,
selectMessagesLoadingOlder,
selectMessagesSyncing selectMessagesSyncing
} from '../../../../store/messages/messages.selectors'; } from '../../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
@@ -72,6 +76,7 @@ export class ChatMessagesComponent {
readonly loading = this.store.selectSignal(selectMessagesLoading); readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly syncing = this.store.selectSignal(selectMessagesSyncing);
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
@@ -83,6 +88,12 @@ export class ChatMessagesComponent {
}); });
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(
switchMap((key) => this.store.select(selectConversationExhausted(key)))
),
{ initialValue: false }
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16); readonly klipyGifPickerAnchorRight = signal(16);
@@ -213,6 +224,22 @@ export class ChatMessagesComponent {
); );
} }
handleLoadOlderRequested(event: { beforeTimestamp: number; limit: number }): void {
const roomId = this.currentRoom()?.id;
if (!roomId)
return;
this.store.dispatch(
MessagesActions.loadOlderMessages({
roomId,
channelId: this.activeChannelId() ?? 'general',
beforeTimestamp: event.beforeTimestamp,
limit: event.limit
})
);
}
toggleKlipyGifPicker(): void { toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker(); const nextState = !this.showKlipyGifPicker();

View File

@@ -2,6 +2,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef, ElementRef,
@@ -153,6 +154,7 @@ interface MissingPluginEmbedFallback {
], ],
templateUrl: './chat-message-item.component.html', templateUrl: './chat-message-item.component.html',
styleUrl: './chat-message-item.component.scss', styleUrl: './chat-message-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
style: 'display: contents;' style: 'display: contents;'
} }

View File

@@ -2,6 +2,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
AfterViewChecked, AfterViewChecked,
ChangeDetectionStrategy,
Component, Component,
ElementRef, ElementRef,
OnDestroy, OnDestroy,
@@ -48,6 +49,7 @@ declare global {
ThemeNodeDirective ThemeNodeDirective
], ],
templateUrl: './chat-message-list.component.html', templateUrl: './chat-message-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
style: 'display: contents;' style: 'display: contents;'
} }
@@ -82,6 +84,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly imageOpened = output<Attachment>(); readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>(); readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
/**
* Emitted when the user scrolls up past the in-store window and the
* component needs the parent to fetch an older page from the DB.
*/
readonly loadOlderRequested = output<{ beforeTimestamp: number; limit: number }>();
/** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
readonly loadingOlder = input(false);
/** True once the parent has paginated all the way back to the start of DB history. */
readonly conversationExhausted = input(false);
private readonly PAGE_SIZE = 50; private readonly PAGE_SIZE = 50;
@@ -141,6 +153,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return lookup; return lookup;
}); });
/**
* O(1) index of messages by id, built once per `allMessages()` change.
* Used by `findRepliedMessage` so each rendered row doing a reply lookup
* costs a Map.get instead of an Array.find over the full message list.
*/
private readonly messagesById = computed<ReadonlyMap<string, Message>>(() => {
const index = new Map<string, Message>();
for (const message of this.allMessages()) {
index.set(message.id, message);
}
return index;
});
private bottomScrollObserver: MutationObserver | null = null; private bottomScrollObserver: MutationObserver | null = null;
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null; private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null; private boundOnImageLoad: (() => void) | null = null;
@@ -150,12 +177,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
private prismHighlightScheduled = false; private prismHighlightScheduled = false;
/**
* Set when an older-page DB fetch is in flight. While true, the
* `onMessagesChanged` effect treats incoming message-count growth as a
* prepend (older history arriving) and preserves the user's scroll
* position instead of running sticky-bottom / new-messages-indicator
* logic.
*/
private pendingOlderFetchScrollHeight: number | null = null;
private readonly onConversationChanged = effect(() => { private readonly onConversationChanged = effect(() => {
void this.conversationKey(); void this.conversationKey();
this.resetScrollingState(); this.resetScrollingState();
}); });
/**
* Clears the in-flight older-fetch flag when the parent reports the
* load has finished (regardless of how many rows were returned, even
* zero). Without this, `loadingMore` would stick on if the DB had no
* rows older than the cursor.
*/
private readonly onLoadingOlderChanged = effect(() => {
const inFlight = this.loadingOlder();
if (!inFlight && this.pendingOlderFetchScrollHeight !== null) {
// If onMessagesChanged already consumed the pending state because
// rows arrived, this is a no-op; otherwise we clear it now.
queueMicrotask(() => {
if (this.pendingOlderFetchScrollHeight !== null) {
this.pendingOlderFetchScrollHeight = null;
this.loadingMore.set(false);
}
});
}
});
private readonly onMessagesChanged = effect(() => { private readonly onMessagesChanged = effect(() => {
const currentCount = this.channelMessages().length; const currentCount = this.channelMessages().length;
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
@@ -170,6 +226,36 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return; return;
} }
// Handle older-history backfill: messages were prepended, not appended.
// Reveal the new rows by widening the display window, and preserve the
// user's visual scroll position across the height change. We skip the
// sticky-bottom / new-messages-indicator logic entirely for this path.
if (this.pendingOlderFetchScrollHeight !== null && currentCount > this.lastMessageCount) {
const previousScrollHeight = this.pendingOlderFetchScrollHeight;
const previousScrollTop = element.scrollTop;
const newlyLoaded = currentCount - this.lastMessageCount;
this.pendingOlderFetchScrollHeight = null;
this.displayLimit.update((limit) => limit + newlyLoaded);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const container = this.messagesContainer?.nativeElement;
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = previousScrollTop + (newScrollHeight - previousScrollHeight);
}
this.loadingMore.set(false);
});
});
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
const forceLocalSendScroll = this.shouldForceLocalSendScroll(); const forceLocalSendScroll = this.shouldForceLocalSendScroll();
@@ -232,7 +318,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (!messageId) if (!messageId)
return undefined; return undefined;
return this.allMessages().find((message) => message.id === messageId); return this.messagesById().get(messageId);
} }
onScroll(): void { onScroll(): void {
@@ -252,32 +338,68 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.stopBottomScrollWatch(); this.stopBottomScrollWatch();
} }
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { if (element.scrollTop < 150 && !this.loadingMore()) {
this.loadMore(); const canFetchOlderFromDb =
!this.hasMoreMessages()
&& !this.conversationExhausted()
&& !this.loadingOlder()
&& this.channelMessages().length > 0;
if (this.hasMoreMessages() || canFetchOlderFromDb) {
this.loadMore();
}
} }
} }
loadMore(): void { loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) if (this.loadingMore())
return; return;
this.loadingMore.set(true); // Case 1: there are still in-store messages above the rendered window.
// Just widen the display window and preserve scroll position.
if (this.hasMoreMessages()) {
this.loadingMore.set(true);
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
const previousScrollHeight = element?.scrollHeight ?? 0; const previousScrollHeight = element?.scrollHeight ?? 0;
this.displayLimit.update((limit) => limit + this.PAGE_SIZE); this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (element) { requestAnimationFrame(() => {
const newScrollHeight = element.scrollHeight; if (element) {
const newScrollHeight = element.scrollHeight;
element.scrollTop += newScrollHeight - previousScrollHeight; element.scrollTop += newScrollHeight - previousScrollHeight;
} }
this.loadingMore.set(false); this.loadingMore.set(false);
});
}); });
return;
}
// Case 2: in-store window is exhausted. Ask the parent to fetch the
// next older page from the DB. The parent dispatches loadOlderMessages
// and the resulting store update is handled by onMessagesChanged via
// pendingOlderFetchScrollHeight (prepend-aware scroll preservation).
if (this.loadingOlder() || this.conversationExhausted())
return;
const all = this.channelMessages();
if (all.length === 0)
return;
const oldest = all[0];
const element = this.messagesContainer?.nativeElement;
this.loadingMore.set(true);
this.pendingOlderFetchScrollHeight = element?.scrollHeight ?? 0;
this.loadOlderRequested.emit({
beforeTimestamp: oldest.timestamp,
limit: this.PAGE_SIZE
}); });
} }
@@ -359,6 +481,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = 0; this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE); this.displayLimit.set(this.PAGE_SIZE);
this.pendingOlderFetchScrollHeight = null;
this.loadingMore.set(false);
} }
private startBottomScrollWatch(): void { private startBottomScrollWatch(): void {

View File

@@ -9,6 +9,7 @@ import {
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import { import {
VoiceActivityService, VoiceActivityService,
VoiceConnectionFacade, VoiceConnectionFacade,
@@ -38,9 +39,11 @@ export class DirectCallService {
private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService); private readonly playback = inject(VoicePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers); private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]); private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
readonly sessions = computed(() => this.sessionsSignal()); readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended')); readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -65,6 +68,15 @@ export class DirectCallService {
}); });
readonly currentSession = signal<DirectCallSession | null>(null); readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0); readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
if (!callId) {
return null;
}
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
});
constructor() { constructor() {
this.delivery.directCallEvents$.subscribe((event) => { this.delivery.directCallEvents$.subscribe((event) => {
@@ -92,6 +104,12 @@ export class DirectCallService {
this.audio.stop(AppSound.Call); this.audio.stop(AppSound.Call);
}); });
effect(() => {
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
this.mobileOverlayCallId.set(null);
}
});
} }
sessionById(callId: string | null | undefined): DirectCallSession | null { sessionById(callId: string | null | undefined): DirectCallSession | null {
@@ -155,7 +173,7 @@ export class DirectCallService {
this.currentSession.set(session); this.currentSession.set(session);
await this.joinCall(session.callId, false); await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session); this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.router.navigate(['/call', session.callId]); await this.openCallView(session.callId);
return session; return session;
} }
@@ -186,6 +204,24 @@ export class DirectCallService {
this.currentSession.set(session); this.currentSession.set(session);
} }
async openCallView(callId: string): Promise<void> {
if (this.viewport.isMobile()) {
await this.openMobileCallOverlay(callId);
return;
}
await this.router.navigate(['/call', callId]);
}
async openMobileCallOverlay(callId: string): Promise<void> {
await this.openCall(callId);
this.mobileOverlayCallId.set(callId);
}
closeMobileCallOverlay(): void {
this.mobileOverlayCallId.set(null);
}
async answerIncomingCall(callId: string): Promise<void> { async answerIncomingCall(callId: string): Promise<void> {
const session = this.sessionById(callId); const session = this.sessionById(callId);

View File

@@ -6,17 +6,39 @@
appThemeNode="dmChatHeader" appThemeNode="dmChatHeader"
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4" class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
> >
<app-user-avatar @if (peerUser()) {
[name]="peerName()" <button
[avatarUrl]="peerUser()?.avatarUrl" type="button"
[status]="peerUser()?.status" class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
[showStatusBadge]="true" [attr.aria-label]="'Open profile for ' + peerName()"
size="md" [title]="'Open profile for ' + peerName()"
/> (click)="openHeaderProfileCard($event)"
<div class="min-w-0 flex-1"> >
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1> <app-user-avatar
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p> [name]="peerName()"
</div> [avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
</div>
</button>
} @else {
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
}
@if (showCallButton() && conversation()) { @if (showCallButton() && conversation()) {
<button <button
type="button" type="button"

View File

@@ -16,7 +16,11 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, UserAvatarComponent } from '../../../../shared'; import {
BottomSheetComponent,
ProfileCardService,
UserAvatarComponent
} from '../../../../shared';
import { DirectCallService } from '../../../direct-call'; import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
@@ -82,6 +86,7 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService); private readonly linkMetadata = inject(LinkMetadataService);
private readonly profileCard = inject(ProfileCardService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>(); private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null; private openedConversationId: string | null = null;
@@ -309,6 +314,17 @@ export class DmChatComponent {
} }
} }
openHeaderProfileCard(event: MouseEvent): void {
const user = this.peerUser();
if (!user) {
return;
}
event.stopPropagation();
this.profileCard.open(event.currentTarget as HTMLElement, user, { editable: false });
}
setReplyTo(message: ChatMessageReplyEvent): void { setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message); this.replyTo.set(message);
} }

View File

@@ -3,7 +3,7 @@
<div class="group/server relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
<button <button
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground" class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
title="Direct Messages" title="Direct Messages"
aria-label="Direct Messages" aria-label="Direct Messages"
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'" [ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
@@ -12,7 +12,7 @@
> >
<ng-icon <ng-icon
name="lucideMessageCircle" name="lucideMessageCircle"
class="h-4 w-4" class="h-[18px] w-[18px] md:h-4 md:w-4"
/> />
@if (directMessages.totalUnreadCount() > 0) { @if (directMessages.totalUnreadCount() > 0) {
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span> <span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
@@ -24,7 +24,7 @@
<div class="group/server relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
<button <button
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card" class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
[class.dm-rail-slide-in]="!item.isExiting" [class.dm-rail-slide-in]="!item.isExiting"
[class.dm-rail-slide-out]="item.isExiting" [class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting" [class.pointer-events-none]="item.isExiting"

View File

@@ -1,6 +1,6 @@
<div <div
appThemeNode="dmConversationItem" appThemeNode="dmConversationItem"
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60" class="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelected()" [class.bg-primary/10]="isSelected()"
[class.text-foreground]="isSelected()" [class.text-foreground]="isSelected()"
[attr.aria-current]="isSelected() ? 'page' : null" [attr.aria-current]="isSelected() ? 'page' : null"

View File

@@ -4,7 +4,8 @@ import {
computed, computed,
effect, effect,
inject, inject,
input input,
output
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -48,6 +49,7 @@ export class DmConversationItemComponent {
private readonly directMessages = inject(DirectMessageService); private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService); private readonly directCalls = inject(DirectCallService);
readonly conversation = input.required<DirectMessageConversation>(); readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers); readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
@@ -71,6 +73,7 @@ export class DmConversationItemComponent {
} }
openConversation(): void { openConversation(): void {
this.conversationOpened.emit(this.conversation().id);
void this.router.navigate(['/dm', this.conversation().id]); void this.router.navigate(['/dm', this.conversation().id]);
} }

View File

@@ -1,6 +1,6 @@
<aside <aside
appThemeNode="dmConversationsPanel" appThemeNode="dmConversationsPanel"
class="flex min-h-0 overflow-hidden border-r border-border bg-card" class="flex min-h-0 w-full min-w-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()" [ngStyle]="listPanelStyles()"
> >
<section class="flex h-full w-full min-w-0 flex-col"> <section class="flex h-full w-full min-w-0 flex-col">
@@ -28,10 +28,12 @@
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div> <div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else { } @else {
<div class="space-y-1"> <div class="space-y-1">
<app-dm-conversation-item @for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId" <app-dm-conversation-item
[conversation]="conversation" [conversation]="conversation"
></app-dm-conversation-item> (conversationOpened)="conversationSelected.emit($event)"
/>
}
</div> </div>
} }
</div> </div>

View File

@@ -2,7 +2,8 @@
import { import {
Component, Component,
computed, computed,
inject inject,
output
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -31,6 +32,7 @@ export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>();
trackConversationId(index: number, conversation: DirectMessageConversation): string { trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id; return conversation.id;

View File

@@ -13,7 +13,10 @@
<div class="flex h-full w-full min-h-0 overflow-hidden"> <div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" /> <app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border"> <div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<app-dm-conversations-panel class="block h-full w-full" /> <app-dm-conversations-panel
(conversationSelected)="setMobilePage('chat')"
class="block h-full w-full"
/>
</div> </div>
</div> </div>
</swiper-slide> </swiper-slide>
@@ -32,7 +35,21 @@
class="h-5 w-5" class="h-5 w-5"
/> />
</button> </button>
<p class="truncate text-sm font-semibold text-foreground">Direct messages</p> <p class="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">Direct messages</p>
@if (activeCall()) {
<button
type="button"
(click)="openActiveCall()"
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
aria-label="Return to call"
title="Return to call"
>
<ng-icon
name="lucidePhoneCall"
class="h-5 w-5"
/>
</button>
}
</div> </div>
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<app-dm-chat-panel class="block h-full w-full" /> <app-dm-chat-panel class="block h-full w-full" />
@@ -50,4 +67,3 @@
<app-dm-chat-panel /> <app-dm-chat-panel />
</div> </div>
} }

View File

@@ -16,10 +16,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronLeft } from '@ng-icons/lucide'; import { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component'; import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { ThemeService } from '../../../theme'; import { ThemeService } from '../../../theme';
import { DirectCallService } from '../../../direct-call';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmChatPanelComponent } from './dm-chat-panel.component'; import { DmChatPanelComponent } from './dm-chat-panel.component';
import { DmConversationsPanelComponent } from './dm-conversations-panel.component'; import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
@@ -47,7 +48,7 @@ interface SwiperElement extends HTMLElement {
DmConversationsPanelComponent, DmConversationsPanelComponent,
ServersRailComponent ServersRailComponent
], ],
viewProviders: [provideIcons({ lucideChevronLeft })], viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dm-workspace.component.html' templateUrl: './dm-workspace.component.html'
}) })
@@ -57,6 +58,7 @@ export class DmWorkspaceComponent implements OnDestroy {
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone); private readonly zone = inject(NgZone);
private readonly directCalls = inject(DirectCallService);
private lastSeenConversationId: string | null = null; private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null; private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
@@ -66,6 +68,12 @@ export class DmWorkspaceComponent implements OnDestroy {
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl'); readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
readonly activeCall = computed(() => {
const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions();
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
});
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations'); readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
@@ -150,6 +158,14 @@ export class DmWorkspaceComponent implements OnDestroy {
this.mobilePage.set(page); this.mobilePage.set(page);
} }
openActiveCall(): void {
const call = this.activeCall();
if (call) {
void this.directCalls.openCallView(call.callId);
}
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId()); this.directMessages.closeConversationView(this.routeConversationId());
} }

View File

@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../../server-directory';
import { resolveRoomPermission } from '../../../access-control';
import { AttachmentFacade } from '../../../attachment';
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade'; import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
import type { import type {
Channel, Channel,
@@ -14,6 +17,7 @@ import type {
User User
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { CHUNK_SIZE, chunkArray } from '../../../../store/messages/messages.helpers';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors'; import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { import {
@@ -24,10 +28,13 @@ import {
} from '../../../../store/rooms/rooms.selectors'; } from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { defaultChannels } from '../../../../store/rooms/room-channels.defaults';
import { isChannelNameTaken, normalizeChannelName } from '../../../../store/rooms/room-channels.rules';
import type { import type {
PluginApiAvatarUpdate, PluginApiAvatarUpdate,
PluginApiActionContext, PluginApiActionContext,
PluginApiActionSource, PluginApiActionSource,
PluginApiAttachmentImportRequest,
PluginApiChannelRequest, PluginApiChannelRequest,
PluginApiCustomStreamRequest, PluginApiCustomStreamRequest,
PluginApiMessageAsPluginUserRequest, PluginApiMessageAsPluginUserRequest,
@@ -44,11 +51,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginClientApiService { export class PluginClientApiService {
private readonly attachments = inject(AttachmentFacade);
private readonly capabilities = inject(PluginCapabilityService); private readonly capabilities = inject(PluginCapabilityService);
private readonly db = inject(DatabaseService); private readonly db = inject(DatabaseService);
private readonly logger = inject(PluginLoggerService); private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService); private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade); private readonly realtime = inject(RealtimeSessionFacade);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService); private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService); private readonly uiRegistry = inject(PluginUiRegistryService);
@@ -71,7 +80,11 @@ export class PluginClientApiService {
channels: { channels: {
addAudioChannel: (request) => { addAudioChannel: (request) => {
requireCapability('channels.manage'); requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') })); this.addPluginManagedChannel(pluginId, createChannel(request, 'voice'));
},
addTextChannel: (request) => {
requireCapability('channels.manage');
this.addPluginManagedChannel(pluginId, createChannel(request, 'text'));
}, },
addVideoChannel: (request) => { addVideoChannel: (request) => {
requireCapability('channels.manage'); requireCapability('channels.manage');
@@ -143,6 +156,15 @@ export class PluginClientApiService {
await this.storage.writeClientData(pluginId, key, value); await this.storage.writeClientData(pluginId, key, value);
} }
}, },
attachments: {
import: async (request: PluginApiAttachmentImportRequest) => {
requireCapability('messages.sync');
const roomId = this.requireRoomId();
this.attachments.rememberMessageRoom(request.messageId, roomId);
await this.attachments.publishAttachments(request.messageId, request.files, this.currentUser()?.id);
}
},
media: { media: {
addCustomAudioStream: async (request) => { addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream'); requireCapability('media.addAudioStream');
@@ -190,6 +212,10 @@ export class PluginClientApiService {
requireCapability('messages.send'); requireCapability('messages.send');
this.receivePluginUserMessage(pluginId, request); this.receivePluginUserMessage(pluginId, request);
}, },
import: async (messages) => {
requireCapability('messages.sync');
await this.importPluginMessages(pluginId, messages);
},
setTyping: (isTyping, channelId) => { setTyping: (isTyping, channelId) => {
requireCapability('messages.send'); requireCapability('messages.send');
this.setTyping(pluginId, isTyping, channelId); this.setTyping(pluginId, isTyping, channelId);
@@ -301,6 +327,58 @@ export class PluginClientApiService {
return userId; return userId;
}, },
updateIcon: async (icon) => {
requireCapability('server.manage');
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room) {
throw new Error('Room not found');
}
if (!currentUser) {
throw new Error('Not logged in');
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
if (!isOwner && !isServerAdmin && !canByRole) {
throw new Error('Permission denied');
}
const iconUpdatedAt = Date.now();
await this.db.updateRoom(room.id, { icon, iconUpdatedAt });
this.store.dispatch(RoomsActions.updateServerIconSuccess({ roomId: room.id, icon, iconUpdatedAt }));
this.realtime.broadcastMessage({
type: 'server-icon-update',
roomId: room.id,
icon,
iconUpdatedAt
});
this.realtime.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt
});
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
currentOwnerId: currentUser.id,
icon,
iconUpdatedAt
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
},
updatePermissions: (permissions) => { updatePermissions: (permissions) => {
requireCapability('server.manage'); requireCapability('server.manage');
this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions })); this.store.dispatch(RoomsActions.updateRoomPermissions({ roomId: this.requireRoomId(), permissions }));
@@ -648,6 +726,106 @@ export class PluginClientApiService {
}); });
} }
private async importPluginMessages(pluginId: string, messages: Message[]): Promise<void> {
const roomId = this.requireRoomId();
const normalizedMessages = messages
.filter((message) => message.roomId === roomId)
.map((message) => ({
...message,
channelId: message.channelId ?? this.activeChannelId() ?? 'general',
isDeleted: message.isDeleted === true,
reactions: message.reactions ?? []
}));
if (normalizedMessages.length === 0) {
return;
}
for (const message of normalizedMessages) {
await this.db.saveMessage(message);
}
this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages }));
// Broadcast imported history to peers in CHUNK_SIZE batches so they don't
// depend on the inventory-limited background sync to discover bulk imports.
for (const chunk of chunkArray(normalizedMessages, CHUNK_SIZE)) {
this.voice.broadcastMessage({
type: 'chat-sync-batch',
roomId,
messages: chunk
} as unknown as ChatEvent);
}
this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length });
}
private addPluginManagedChannel(pluginId: string, channel: Channel): void {
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room || !currentUser) {
return;
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
const canManageChannels = resolveRoomPermission(room, currentUser, 'manageChannels');
if (!isOwner && !isServerAdmin && !canManageChannels) {
this.logger.warn(pluginId, 'Plugin channel creation denied by room permissions', {
channelId: channel.id,
roomId: room.id
});
return;
}
const existingChannels = room.channels ?? defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
const channelExists = existingChannels.some((entry) => entry.id === channel.id) ||
isChannelNameTaken(existingChannels, normalizedName, channel.type);
if (!normalizedName || channelExists) {
return;
}
const channels = [
...existingChannels,
{ ...channel,
name: normalizedName }
];
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { channels } }));
void this.db.updateRoom(room.id, { channels }).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin-created channel', error);
});
this.realtime.broadcastMessage({
type: 'channels-update',
roomId: room.id,
channels
});
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
channels,
currentOwnerId: currentUser.id
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
this.logger.info(pluginId, 'Plugin channel created', {
channelId: channel.id,
roomId: room.id
});
}
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void { private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
void this.db.updateMessage(messageId, updates).catch((error: unknown) => { void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message update', error); this.logger.warn(pluginId, 'Failed to persist plugin message update', error);

View File

@@ -1107,7 +1107,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)), githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')), homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
id, id,
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')), imageUrl: normalizeImageUrl(resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner'))),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')), installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')), readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
scope: readPluginInstallScope(value), scope: readPluginInstallScope(value),
@@ -1300,6 +1300,44 @@ function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
} }
} }
/**
* Rewrites human-friendly GitHub URLs so the browser can load the underlying
* binary asset. Users typically paste links copied from the GitHub web UI which
* point at the rendered HTML preview (`github.com/<owner>/<repo>/blob/...`) or
* the raw redirector (`github.com/<owner>/<repo>/raw/...`). Both forms must be
* mapped to `raw.githubusercontent.com` for `<img>` tags to work.
*/
function normalizeImageUrl(rawUrl: string | undefined): string | undefined {
if (!rawUrl) {
return undefined;
}
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return rawUrl;
}
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
return rawUrl;
}
const segments = url.pathname.split('/').filter(Boolean);
const kindIndex = segments.findIndex((segment) => segment === 'blob' || segment === 'raw');
if (kindIndex < 2 || kindIndex >= segments.length - 1) {
return rawUrl;
}
const owner = segments[0];
const repo = segments[1];
const ref = segments.slice(kindIndex + 1).join('/');
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}${url.search}`;
}
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined { function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
if (!rawUrl) { if (!rawUrl) {
return undefined; return undefined;

View File

@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
url: string; url: string;
} }
export interface PluginApiAttachmentImportRequest {
files: File[];
messageId: string;
}
export interface PluginApiCustomStreamRequest { export interface PluginApiCustomStreamRequest {
label?: string; label?: string;
stream: MediaStream; stream: MediaStream;
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
export interface TojuClientPluginApi { export interface TojuClientPluginApi {
readonly channels: { readonly channels: {
addAudioChannel: (request: PluginApiChannelRequest) => void; addAudioChannel: (request: PluginApiChannelRequest) => void;
addTextChannel: (request: PluginApiChannelRequest) => void;
addVideoChannel: (request: PluginApiChannelRequest) => void; addVideoChannel: (request: PluginApiChannelRequest) => void;
list: () => Channel[]; list: () => Channel[];
remove: (channelId: string) => void; remove: (channelId: string) => void;
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
remove: (key: string) => Promise<void>; remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>; write: (key: string, value: unknown) => Promise<void>;
}; };
readonly attachments: {
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
};
readonly media: { readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>; addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>; addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
readCurrent: () => Message[]; readCurrent: () => Message[];
send: (content: string, channelId?: string) => Message; send: (content: string, channelId?: string) => Message;
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void; sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
import: (messages: Message[]) => Promise<void>;
setTyping: (isTyping: boolean, channelId?: string) => void; setTyping: (isTyping: boolean, channelId?: string) => void;
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable; subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
sync: (messages: Message[]) => void; sync: (messages: Message[]) => void;
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
readonly server: { readonly server: {
getCurrent: () => Room | null; getCurrent: () => Room | null;
registerPluginUser: (request: PluginApiPluginUserRequest) => string; registerPluginUser: (request: PluginApiPluginUserRequest) => string;
updateIcon: (icon: string) => Promise<void>;
updatePermissions: (permissions: Partial<RoomPermissions>) => void; updatePermissions: (permissions: Partial<RoomPermissions>) => void;
updateSettings: (settings: PluginApiServerSettingsUpdate) => void; updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
}; };

View File

@@ -1,13 +1,13 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity --> <!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section <section
class="flex h-full min-h-0 flex-col bg-background text-foreground" class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
data-testid="plugin-manager" data-testid="plugin-manager"
> >
<header class="flex items-center justify-between border-b border-border px-4 py-3"> <header class="flex flex-col gap-3 border-b border-border px-3 py-3 md:flex-row md:items-center md:justify-between md:px-4">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3 md:flex-1">
<button <button
type="button" type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground" class="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground md:h-8 md:w-8"
aria-label="Back to settings" aria-label="Back to settings"
(click)="close()" (click)="close()"
> >
@@ -21,38 +21,40 @@
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p> <p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div> </div>
</div> </div>
<button <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
type="button" <button
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50" type="button"
[disabled]="busyAll()" class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
(click)="activateAll()" [disabled]="busyAll()"
> (click)="activateAll()"
<ng-icon >
name="lucidePlay" <ng-icon
size="16" name="lucidePlay"
/> size="16"
Activate ready plugins />
</button> Activate ready plugins
<button </button>
type="button" <button
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted" type="button"
(click)="openStore()" class="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
> (click)="openStore()"
<ng-icon >
name="lucideStore" <ng-icon
size="16" name="lucideStore"
/> size="16"
Open Plugin Store />
</button> Open Plugin Store
</button>
</div>
</header> </header>
<nav <nav
class="flex gap-2 border-b border-border px-4 py-2" class="no-scrollbar flex gap-2 overflow-x-auto border-b border-border px-3 py-2 md:px-4"
aria-label="Plugin manager sections" aria-label="Plugin manager sections"
> >
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'installed'" [class.bg-muted]="activeTab() === 'installed'"
(click)="setTab('installed')" (click)="setTab('installed')"
> >
@@ -64,7 +66,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'extensions'" [class.bg-muted]="activeTab() === 'extensions'"
(click)="setTab('extensions')" (click)="setTab('extensions')"
> >
@@ -76,7 +78,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'requirements'" [class.bg-muted]="activeTab() === 'requirements'"
(click)="setTab('requirements')" (click)="setTab('requirements')"
> >
@@ -88,7 +90,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'settings'" [class.bg-muted]="activeTab() === 'settings'"
(click)="setTab('settings')" (click)="setTab('settings')"
> >
@@ -100,7 +102,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'docs'" [class.bg-muted]="activeTab() === 'docs'"
(click)="setTab('docs')" (click)="setTab('docs')"
> >
@@ -112,7 +114,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-2 rounded-md px-3 text-sm" class="inline-flex h-11 flex-shrink-0 items-center gap-2 rounded-md px-3 text-sm md:h-8"
[class.bg-muted]="activeTab() === 'logs'" [class.bg-muted]="activeTab() === 'logs'"
(click)="setTab('logs')" (click)="setTab('logs')"
> >
@@ -124,7 +126,7 @@
</button> </button>
</nav> </nav>
<div class="min-h-0 flex-1 overflow-auto p-4"> <div class="min-h-0 flex-1 overflow-auto p-3 md:p-4">
@switch (activeTab()) { @switch (activeTab()) {
@case ('extensions') { @case ('extensions') {
<div class="space-y-4"> <div class="space-y-4">
@@ -216,7 +218,7 @@
@for (entry of entries(); track trackEntry($index, entry)) { @for (entry of entries(); track trackEntry($index, entry)) {
<button <button
type="button" type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted" class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)" [class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)" (click)="selectPlugin(entry.manifest.id)"
> >
@@ -224,7 +226,7 @@
</button> </button>
} }
</div> </div>
<section class="rounded-lg border border-border bg-card p-4"> <section class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) { @if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3> <h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
@if (selectedSettingsPages().length > 0) { @if (selectedSettingsPages().length > 0) {
@@ -255,7 +257,7 @@
@for (entry of entries(); track trackEntry($index, entry)) { @for (entry of entries(); track trackEntry($index, entry)) {
<button <button
type="button" type="button"
class="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted" class="min-h-11 w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)" [class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)" (click)="selectPlugin(entry.manifest.id)"
> >
@@ -263,14 +265,14 @@
</button> </button>
} }
</div> </div>
<section class="rounded-lg border border-border bg-card p-4"> <section class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) { @if (selectedPlugin(); as plugin) {
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3> <h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p> <p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
@for (doc of selectedDocs(); track doc.label) { @for (doc of selectedDocs(); track doc.label) {
<a <a
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted" class="inline-flex min-h-11 items-center rounded-md border border-border px-3 py-1.5 text-sm hover:bg-muted md:min-h-0"
[href]="doc.url" [href]="doc.url"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@@ -292,7 +294,7 @@
@for (entry of entries(); track trackEntry($index, entry)) { @for (entry of entries(); track trackEntry($index, entry)) {
<button <button
type="button" type="button"
class="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted" class="min-h-11 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted md:min-h-0"
[class.bg-muted]="isSelected(entry)" [class.bg-muted]="isSelected(entry)"
(click)="selectPlugin(entry.manifest.id)" (click)="selectPlugin(entry.manifest.id)"
> >
@@ -323,7 +325,7 @@
<div class="space-y-3"> <div class="space-y-3">
@if (entries().length === 0) { @if (entries().length === 0) {
<div <div
class="rounded-lg border border-dashed border-border p-8 text-center" class="rounded-lg border border-dashed border-border p-5 text-center md:p-8"
data-testid="plugin-empty-state" data-testid="plugin-empty-state"
> >
<ng-icon <ng-icon
@@ -337,7 +339,7 @@
} @else { } @else {
@for (entry of entries(); track trackEntry($index, entry)) { @for (entry of entries(); track trackEntry($index, entry)) {
<article <article
class="rounded-lg border border-border bg-card p-4" class="rounded-lg border border-border bg-card p-3 md:p-4"
[class.ring-2]="isSelected(entry)" [class.ring-2]="isSelected(entry)"
[class.ring-primary]="isSelected(entry)" [class.ring-primary]="isSelected(entry)"
> >
@@ -351,17 +353,17 @@
<p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p> <p class="mt-1 text-sm text-muted-foreground">{{ entry.manifest.description }}</p>
<p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p> <p class="mt-2 text-xs text-muted-foreground">{{ entry.manifest.id }}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:flex-wrap">
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted" class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="selectPlugin(entry.manifest.id)" (click)="selectPlugin(entry.manifest.id)"
> >
Select Select
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted" class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="setEnabled(entry, !entry.enabled)" (click)="setEnabled(entry, !entry.enabled)"
> >
<ng-icon <ng-icon
@@ -372,7 +374,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50" class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)" [disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
(click)="activate(entry)" (click)="activate(entry)"
> >
@@ -384,7 +386,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50" class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id" [disabled]="busyPluginId() === entry.manifest.id"
(click)="reload(entry)" (click)="reload(entry)"
> >
@@ -396,7 +398,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50" class="inline-flex min-h-11 items-center justify-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50 md:h-8 md:min-h-0"
[disabled]="busyPluginId() === entry.manifest.id" [disabled]="busyPluginId() === entry.manifest.id"
(click)="unload(entry)" (click)="unload(entry)"
> >
@@ -416,7 +418,7 @@
} }
</div> </div>
<aside class="rounded-lg border border-border bg-card p-4"> <aside class="rounded-lg border border-border bg-card p-3 md:p-4">
@if (selectedPlugin(); as plugin) { @if (selectedPlugin(); as plugin) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ng-icon <ng-icon
@@ -430,14 +432,14 @@
} @else { } @else {
<button <button
type="button" type="button"
class="mt-3 h-8 rounded-md border border-border px-3 text-sm hover:bg-muted" class="mt-3 min-h-11 rounded-md border border-border px-3 text-sm hover:bg-muted md:h-8 md:min-h-0"
(click)="grantAll(plugin)" (click)="grantAll(plugin)"
> >
Grant all requested Grant all requested
</button> </button>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) { @for (capability of plugin.manifest.capabilities; track trackCapability($index, capability)) {
<label class="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm"> <label class="flex min-h-11 items-center gap-2 rounded-md border border-border px-3 py-2 text-sm md:min-h-0">
<input <input
type="checkbox" type="checkbox"
class="h-4 w-4" class="h-4 w-4"

View File

@@ -257,11 +257,13 @@
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) { @for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]"> <article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full"> <div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
@if (plugin.imageUrl) { @if (plugin.imageUrl && !hasBrokenImage(plugin)) {
<img <img
[src]="plugin.imageUrl" [src]="plugin.imageUrl"
[alt]="plugin.title" [alt]="plugin.title"
(error)="hideBrokenImage($event)" (error)="hideBrokenImage($event, plugin)"
loading="lazy"
referrerpolicy="no-referrer"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
} @else { } @else {

View File

@@ -155,6 +155,7 @@ export class PluginStoreComponent implements OnInit {
readonly serverInstallOptional = signal(false); readonly serverInstallOptional = signal(false);
readonly serverInstallError = signal<string | null>(null); readonly serverInstallError = signal<string | null>(null);
readonly serverInstallBusy = signal(false); readonly serverInstallBusy = signal(false);
readonly brokenImageKeys = signal<Set<string>>(new Set());
private destroyed = false; private destroyed = false;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -530,12 +531,26 @@ export class PluginStoreComponent implements OnInit {
return `${plugin.sourceUrl}:${plugin.id}`; return `${plugin.sourceUrl}:${plugin.id}`;
} }
hideBrokenImage(event: Event): void { hideBrokenImage(event: Event, plugin: PluginStoreEntry): void {
const image = event.target as HTMLImageElement | null; const image = event.target as HTMLImageElement | null;
if (image) { if (image) {
image.hidden = true; image.hidden = true;
} }
const key = this.imageKey(plugin);
const next = new Set(this.brokenImageKeys());
next.add(key);
this.brokenImageKeys.set(next);
}
hasBrokenImage(plugin: PluginStoreEntry): boolean {
return this.brokenImageKeys().has(this.imageKey(plugin));
}
private imageKey(plugin: PluginStoreEntry): string {
return `${plugin.sourceUrl}:${plugin.id}:${plugin.imageUrl ?? ''}`;
} }
trackServer(index: number, server: Room): string { trackServer(index: number, server: Room): string {

View File

@@ -22,17 +22,32 @@
<h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1> <h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1>
<button @if (!currentUser()) {
type="button" <button
aria-label="Settings" type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80" aria-label="Log in"
(click)="openSettings()" class="inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
> (click)="goLogin()"
<ng-icon >
name="lucideSettings" <ng-icon
class="h-5 w-5" name="lucideLogIn"
/> class="h-5 w-5"
</button> />
<span>Log in</span>
</button>
} @else {
<button
type="button"
aria-label="Settings"
class="grid h-11 w-11 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
(click)="openSettings()"
>
<ng-icon
name="lucideSettings"
class="h-5 w-5"
/>
</button>
}
</div> </div>
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">

View File

@@ -27,7 +27,8 @@ import {
lucideGlobe, lucideGlobe,
lucidePlus, lucidePlus,
lucideSettings, lucideSettings,
lucideChevronDown lucideChevronDown,
lucideLogIn
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@@ -94,7 +95,8 @@ interface JoinPluginConsentDialog {
lucideGlobe, lucideGlobe,
lucidePlus, lucidePlus,
lucideSettings, lucideSettings,
lucideChevronDown lucideChevronDown,
lucideLogIn
}) })
], ],
templateUrl: './server-search.component.html' templateUrl: './server-search.component.html'
@@ -246,6 +248,11 @@ export class ServerSearchComponent implements OnInit {
this.settingsModal.open('network'); this.settingsModal.open('network');
} }
/** Navigate to the login screen, preserving the search route as the return URL. */
goLogin(): void {
this.router.navigate(['/login'], { queryParams: { returnUrl: '/search' } });
}
/** /**
* Navigate back from the Search page to the chat-room view (server rail + current server). * Navigate back from the Search page to the chat-room view (server rail + current server).
* Prefers the current room; falls back to the first saved room. No-op when the user has not * Prefers the current room; falls back to the first saved room. No-op when the user has not

View File

@@ -1,4 +1,4 @@
<div class="flex w-full flex-wrap items-center justify-center gap-3 rounded-2xl bg-background/75 px-4 py-3 backdrop-blur"> <div class="flex w-full flex-wrap items-center justify-center gap-3 px-3 py-3 sm:px-4">
@if (!connected()) { @if (!connected()) {
<button <button
type="button" type="button"

View File

@@ -1,11 +1,6 @@
<article <article
class="flex aspect-square min-w-0 flex-col items-center justify-center overflow-hidden rounded-2xl border border-border/80 bg-card/80 text-center shadow-sm backdrop-blur" class="flex min-w-0 flex-col items-center justify-center overflow-hidden rounded-xl text-center"
[class.w-[11rem]]="compact()" [ngClass]="compact() ? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4' : 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'"
[class.shrink-0]="compact()"
[class.p-4]="compact()"
[class.sm:w-[12.5rem]]="compact()"
[class.w-full]="!compact()"
[class.p-[clamp(1rem,4vw,1.5rem)]]="!compact()"
> >
<div <div
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]" class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"
@@ -67,16 +62,9 @@
@if (connected()) { @if (connected()) {
<span <span
class="absolute rounded-full border-card" class="absolute rounded-full border-card"
[class.bottom-3]="compact()" [ngClass]="
[class.right-3]="compact()" compact() ? 'bottom-1 right-1 h-4 w-4 border-[3px] sm:bottom-3 sm:right-3' : 'bottom-1 right-1 h-5 w-5 border-4 sm:bottom-5 sm:right-5'
[class.h-4]="compact()" "
[class.w-4]="compact()"
[class.border-[3px]]="compact()"
[class.bottom-5]="!compact()"
[class.right-5]="!compact()"
[class.h-5]="!compact()"
[class.w-5]="!compact()"
[class.border-4]="!compact()"
[class.bg-emerald-400]="speaking()" [class.bg-emerald-400]="speaking()"
[class.bg-muted-foreground]="!speaking()" [class.bg-muted-foreground]="!speaking()"
></span> ></span>

View File

@@ -20,11 +20,11 @@ export class PrivateCallParticipantCardComponent {
readonly compact = input(false); readonly compact = input(false);
avatarSize(): string { avatarSize(): string {
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)'; return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';
} }
avatarSizeSm(): string { avatarSizeSm(): string {
return this.compact() ? '6rem' : this.avatarSize(); return this.compact() ? '6rem' : 'clamp(4.25rem, 22vw, 10rem)';
} }
participantInitial(): string { participantInitial(): string {

View File

@@ -1,9 +1,30 @@
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full"
direction="vertical"
slides-per-view="1"
space-between="0"
initial-slide="1"
threshold="10"
resistance-ratio="0"
(swiperslidechange)="onMobileCallSlideChange($event)"
>
<swiper-slide class="block h-full w-full" />
<swiper-slide class="block h-full w-full">
<ng-container *ngTemplateOutlet="privateCallSurface" />
</swiper-slide>
</swiper-container>
} @else {
<ng-container *ngTemplateOutlet="privateCallSurface" />
}
<ng-template #privateCallSurface>
<section <section
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]" class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
[style.--private-call-chat-width]="chatWidthPx() + 'px'" [style.--private-call-chat-width]="chatWidthPx() + 'px'"
> >
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]"> <main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-5 backdrop-blur"> <header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500"> <div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<ng-icon <ng-icon
@@ -26,8 +47,22 @@
@if (session()) { @if (session()) {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@if (isMobile()) {
<button
type="button"
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
(click)="minimizeCall()"
aria-label="Minimize call"
title="Minimize call"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
/>
</button>
}
<select <select
class="h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground" class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
[ngModel]="inviteUserId()" [ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)" (ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call" aria-label="Add user to call"
@@ -39,7 +74,7 @@
</select> </select>
<button <button
type="button" type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50" class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
[disabled]="!inviteUserId()" [disabled]="!inviteUserId()"
(click)="inviteSelectedUser()" (click)="inviteSelectedUser()"
aria-label="Add user" aria-label="Add user"
@@ -55,8 +90,8 @@
</header> </header>
@if (session()) { @if (session()) {
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5"> <div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm"> <div class="relative min-h-0 flex-1 overflow-hidden">
@if (activeShares().length > 0) { @if (activeShares().length > 0) {
@if (focusedShare()) { @if (focusedShare()) {
@if (hasMultipleShares()) { @if (hasMultipleShares()) {
@@ -103,17 +138,18 @@
</div> </div>
} }
} @else { } @else {
<div class="flex h-full min-h-0 items-center justify-center p-4 sm:p-6"> <div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
<div <div
class="grid w-full max-w-5xl grid-cols-[repeat(auto-fit,minmax(min(10rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(13rem,100%),1fr))] sm:gap-5 lg:gap-7" class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
> >
<app-private-call-participant-card @for (user of participantUsers(); track trackUserKey($index, user)) {
*ngFor="let user of participantUsers(); trackBy: trackUserKey" <app-private-call-participant-card
[user]="user" [user]="user"
[connected]="isParticipantConnected(user)" [connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)" [speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)" [issueLabel]="participantIssueLabel(user)"
></app-private-call-participant-card> />
}
</div> </div>
</div> </div>
} }
@@ -122,14 +158,15 @@
@if (activeShares().length > 0) { @if (activeShares().length > 0) {
<div class="shrink-0 pt-4"> <div class="shrink-0 pt-4">
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1"> <div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
<app-private-call-participant-card @for (user of participantUsers(); track trackUserKey($index, user)) {
*ngFor="let user of participantUsers(); trackBy: trackUserKey" <app-private-call-participant-card
[user]="user" [user]="user"
[connected]="isParticipantConnected(user)" [connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)" [speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)" [issueLabel]="participantIssueLabel(user)"
[compact]="true" [compact]="true"
></app-private-call-participant-card> />
}
@if (hasMultipleShares()) { @if (hasMultipleShares()) {
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) { @for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
@@ -166,7 +203,7 @@
(cameraToggled)="toggleCamera()" (cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()" (screenShareToggled)="toggleScreenShare()"
(leaveRequested)="leave()" (leaveRequested)="leave()"
></app-private-call-controls> />
</div> </div>
</div> </div>
} @else { } @else {
@@ -191,6 +228,7 @@
/> />
</aside> </aside>
</section> </section>
</ng-template>
@if (showScreenShareQualityDialog()) { @if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog <app-screen-share-quality-dialog

View File

@@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
DestroyRef, DestroyRef,
HostListener, HostListener,
computed, computed,
effect, effect,
inject, inject,
input,
signal, signal,
untracked untracked
} from '@angular/core'; } from '@angular/core';
@@ -17,6 +19,7 @@ import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucidePhone, lucidePhone,
lucideX,
lucideUsers, lucideUsers,
lucideUserPlus lucideUserPlus
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
@@ -39,6 +42,7 @@ import {
} from '../../domains/screen-share'; } from '../../domains/screen-share';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session'; import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
import { ScreenShareQualityDialogComponent } from '../../shared'; import { ScreenShareQualityDialogComponent } from '../../shared';
import { ViewportService } from '../../core/platform';
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
import { UsersActions } from '../../store/users/users.actions'; import { UsersActions } from '../../store/users/users.actions';
import { User } from '../../shared-kernel'; import { User } from '../../shared-kernel';
@@ -60,9 +64,12 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
ScreenShareQualityDialogComponent, ScreenShareQualityDialogComponent,
VoiceWorkspaceStreamTileComponent VoiceWorkspaceStreamTileComponent
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
host: { class: 'block h-full w-full' },
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucidePhone, lucidePhone,
lucideX,
lucideUsers, lucideUsers,
lucideUserPlus lucideUserPlus
}) })
@@ -79,13 +86,18 @@ export class PrivateCallComponent {
private readonly voiceActivity = inject(VoiceActivityService); private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService); private readonly playback = inject(VoicePlaybackService);
private readonly screenShare = inject(ScreenShareFacade); private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private chatResizing = false; private chatResizing = false;
readonly allUsers = this.store.selectSignal(selectAllUsers); readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly callId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), { readonly isMobile = this.viewport.isMobile;
readonly callIdInput = input<string | null>(null);
readonly overlayMode = input(false);
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
initialValue: this.route.snapshot.paramMap.get('callId') initialValue: this.route.snapshot.paramMap.get('callId')
}); });
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
readonly session = computed(() => this.calls.sessionById(this.callId())); readonly session = computed(() => this.calls.sessionById(this.callId()));
readonly participantUsers = computed(() => { readonly participantUsers = computed(() => {
const session = this.session(); const session = this.session();
@@ -146,13 +158,11 @@ export class PrivateCallComponent {
} }
for (const user of this.participantUsers()) { for (const user of this.participantUsers()) {
const peerKey = this.getPeerKeyCandidates(user).find( const peerKey =
(candidate) => candidate !== localPeerKey this.getPeerKeyCandidates(user).find(
&& ( (candidate) =>
!!this.screenShare.getRemoteScreenShareStream(candidate) candidate !== localPeerKey && (!!this.screenShare.getRemoteScreenShareStream(candidate) || !!this.voice.getRemoteCameraStream(candidate))
|| !!this.voice.getRemoteCameraStream(candidate) ) ?? this.userKey(user);
)
) ?? this.userKey(user);
if (peerKey === localPeerKey) { if (peerKey === localPeerKey) {
continue; continue;
@@ -192,9 +202,7 @@ export class PrivateCallComponent {
return null; return null;
}); });
readonly focusedShare = computed( readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null);
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
);
readonly thumbnailShares = computed(() => { readonly thumbnailShares = computed(() => {
const focusedShareId = this.focusedShareId(); const focusedShareId = this.focusedShareId();
@@ -217,14 +225,31 @@ export class PrivateCallComponent {
const session = this.session(); const session = this.session();
if (session && !this.calls.hasOngoingActivity(session)) { if (session && !this.calls.hasOngoingActivity(session)) {
if (this.overlayMode()) {
untracked(() => this.calls.closeMobileCallOverlay());
return;
}
untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true })); untracked(() => void this.router.navigate(['/dm', session.conversationId], { replaceUrl: true }));
} }
}); });
effect(() => {
const callId = this.callId();
const session = this.session();
if (callId && session?.conversationId && this.isMobile() && !this.overlayMode()) {
untracked(() => {
void this.calls.openMobileCallOverlay(callId);
void this.router.navigate(['/pm', session.conversationId], { replaceUrl: true });
});
}
});
effect(() => { effect(() => {
const session = this.session(); const session = this.session();
const currentUserId = this.currentUserKey(); const currentUserId = this.currentUserKey();
const peerIds = (session ? this.remoteParticipantPeerIds(session, currentUserId) : []); const peerIds = session ? this.remoteParticipantPeerIds(session, currentUserId) : [];
this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected'); this.screenShare.syncRemoteScreenShareRequests(peerIds, this.isConnected() && !!session && session.status === 'connected');
}); });
@@ -240,13 +265,9 @@ export class PrivateCallComponent {
this.untrackLocalMic(); this.untrackLocalMic();
}); });
this.screenShare.onRemoteStream this.screenShare.onRemoteStream.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.screenShare.onPeerDisconnected this.screenShare.onPeerDisconnected.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
this.screenShare.syncRemoteScreenShareRequests([], false); this.screenShare.syncRemoteScreenShareRequests([], false);
@@ -284,10 +305,38 @@ export class PrivateCallComponent {
} }
this.calls.leaveCall(session.callId); this.calls.leaveCall(session.callId);
this.calls.closeMobileCallOverlay();
this.untrackLocalMic(); this.untrackLocalMic();
if (!this.overlayMode()) {
void this.router.navigate(['/dm', session.conversationId]);
}
}
minimizeCall(): void {
const session = this.session();
if (!session) {
return;
}
if (this.overlayMode()) {
this.calls.closeMobileCallOverlay();
return;
}
void this.router.navigate(['/dm', session.conversationId]); void this.router.navigate(['/dm', session.conversationId]);
} }
onMobileCallSlideChange(event: Event): void {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
if (this.isMobile() && swiper?.activeIndex === 0) {
this.minimizeCall();
}
}
toggleMute(): void { toggleMute(): void {
this.voice.toggleMute(!this.isMuted()); this.voice.toggleMute(!this.isMuted());
this.broadcastLocalVoiceState(); this.broadcastLocalVoiceState();
@@ -378,12 +427,10 @@ export class PrivateCallComponent {
return false; return false;
} }
return !!session.participants[userId]?.joined return (
|| !!( !!session.participants[userId]?.joined ||
user.voiceState?.isConnected !!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
&& user.voiceState.roomId === session.callId );
&& user.voiceState.serverId === session.callId
);
} }
participantIssueLabel(user: User): string | null { participantIssueLabel(user: User): string | null {
@@ -437,16 +484,18 @@ export class PrivateCallComponent {
return; return;
} }
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(
userId: user.id, UsersActions.updateVoiceState({
voiceState: { userId: user.id,
isConnected: this.isConnected(), voiceState: {
isMuted: this.isMuted(), isConnected: this.isConnected(),
isDeafened: this.isDeafened(), isMuted: this.isMuted(),
roomId: session.callId, isDeafened: this.isDeafened(),
serverId: session.callId roomId: session.callId,
} serverId: session.callId
})); }
})
);
} }
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] { private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {

View File

@@ -17,6 +17,7 @@
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card"> <div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
<app-rooms-side-panel <app-rooms-side-panel
panelMode="channels" panelMode="channels"
(textChannelSelected)="setMobilePage('main')"
class="block h-full w-full" class="block h-full w-full"
/> />
</div> </div>
@@ -52,6 +53,20 @@
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p> <p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p>
} }
</div> </div>
@if (activeCall()) {
<button
type="button"
(click)="openActiveCall()"
class="grid h-11 w-11 place-items-center rounded-lg text-emerald-600 transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
aria-label="Return to call"
title="Return to call"
>
<ng-icon
name="lucidePhoneCall"
class="h-5 w-5"
/>
</button>
}
<button <button
type="button" type="button"
(click)="setMobilePage('members')" (click)="setMobilePage('members')"
@@ -208,4 +223,3 @@
</div> </div>
} }
</div> </div>

View File

@@ -20,7 +20,8 @@ import {
lucideUsers, lucideUsers,
lucideMenu, lucideMenu,
lucideX, lucideX,
lucideChevronLeft lucideChevronLeft,
lucidePhoneCall
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component'; import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
@@ -38,6 +39,7 @@ import { ViewportService } from '../../../core/platform';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme'; import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
import { DirectCallService } from '../../../domains/direct-call';
/** Mobile-only page identifier within the chat-room view. */ /** Mobile-only page identifier within the chat-room view. */
export type ChatRoomMobilePage = 'channels' | 'main' | 'members'; export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
@@ -77,7 +79,8 @@ interface SwiperElement extends HTMLElement {
lucideUsers, lucideUsers,
lucideMenu, lucideMenu,
lucideX, lucideX,
lucideChevronLeft lucideChevronLeft,
lucidePhoneCall
}) })
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@@ -96,6 +99,7 @@ export class ChatRoomComponent {
private readonly settingsModal = inject(SettingsModalService); private readonly settingsModal = inject(SettingsModalService);
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
private readonly directCalls = inject(DirectCallService);
private readonly zone = inject(NgZone); private readonly zone = inject(NgZone);
private voiceWorkspace = inject(VoiceWorkspaceService); private voiceWorkspace = inject(VoiceWorkspaceService);
private lastSeenChannelId: string | null = null; private lastSeenChannelId: string | null = null;
@@ -128,6 +132,12 @@ export class ChatRoomComponent {
}); });
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0); hasTextChannels = computed(() => this.textChannels().length > 0);
activeCall = computed(() => {
const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions();
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
});
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel')); channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel')); mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
@@ -209,6 +219,14 @@ export class ChatRoomComponent {
this.mobilePage.set(page); this.mobilePage.set(page);
} }
openActiveCall(): void {
const call = this.activeCall();
if (call) {
void this.directCalls.openCallView(call.callId);
}
}
/** Open the settings modal to the Server admin page for the current room. */ /** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() { toggleAdminPanel() {
const room = this.currentRoom(); const room = this.currentRoom();

View File

@@ -5,6 +5,7 @@ import {
computed, computed,
input, input,
OnDestroy, OnDestroy,
output,
signal signal
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@@ -138,6 +139,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
readonly panelMode = input<PanelMode>('channels'); readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true); readonly showVoiceControls = input(true);
readonly textChannelSelected = output<string>();
showFloatingControls = this.voiceSessionService.showFloatingControls; showFloatingControls = this.voiceSessionService.showFloatingControls;
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -379,6 +381,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.voiceWorkspace.showChat(); this.voiceWorkspace.showChat();
this.store.dispatch(RoomsActions.selectChannel({ channelId })); this.store.dispatch(RoomsActions.selectChannel({ channelId }));
this.textChannelSelected.emit(channelId);
} }
openChannelContextMenu(evt: MouseEvent, channel: Channel) { openChannelContextMenu(evt: MouseEvent, channel: Channel) {

View File

@@ -63,15 +63,45 @@
</div> </div>
</div> </div>
@if (!item().isLocal && item().hasAudio) { @if (canControlStreamAudio()) {
<div class="flex min-w-32 items-center gap-2 rounded-full border border-white/10 bg-black/35 px-2.5 py-1.5 text-white/75">
<button
type="button"
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-4 w-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="volume()"
class="w-20 accent-primary sm:w-28"
aria-label="Stream volume"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
<span class="w-9 text-right text-xs tabular-nums">{{ muted() ? 'Off' : volume() + '%' }}</span>
</div>
}
@if (isMobile() && item().kind === 'screen') {
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'" title="Rotate to landscape"
(click)="toggleMuted(); $event.stopPropagation()" aria-label="Rotate to landscape"
(click)="enterLandscapeFullscreen($event)"
> >
<ng-icon <ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'" name="lucideRotateCw"
class="h-4 w-4" class="h-4 w-4"
/> />
</button> </button>
@@ -92,6 +122,72 @@
</div> </div>
} }
@if (immersive() && item().kind === 'screen' && !isFullscreen()) {
<div class="absolute inset-x-3 bottom-3 z-20 sm:inset-x-5 sm:bottom-5">
<div class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4">
@if (canControlStreamAudio()) {
<div class="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-white/10 px-2.5 py-2 sm:max-w-md">
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-white/85 transition hover:bg-white/10 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
[attr.aria-label]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-4 w-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="volume()"
class="min-w-0 flex-1 accent-primary"
aria-label="Screen share volume"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
<span class="w-10 text-right text-xs font-semibold tabular-nums text-white/70">{{ muted() ? 'Off' : volume() + '%' }}</span>
</div>
} @else {
<div class="min-w-0 flex-1 px-2 text-center text-xs font-medium text-white/65 sm:text-left">No screen audio</div>
}
<button
type="button"
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
(click)="toggleFullscreen($event)"
>
<ng-icon
name="lucideMaximize"
class="h-5 w-5"
/>
</button>
@if (isMobile()) {
<button
type="button"
class="grid h-11 w-11 place-items-center rounded-full bg-white/10 text-white transition hover:bg-white/15"
title="Rotate to landscape"
aria-label="Rotate to landscape"
(click)="enterLandscapeFullscreen($event)"
>
<ng-icon
name="lucideRotateCw"
class="h-5 w-5"
/>
</button>
}
</div>
</div>
}
@if (mini()) { @if (mini()) {
<div class="absolute inset-x-0 bottom-0 p-2"> <div class="absolute inset-x-0 bottom-0 p-2">
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md"> <div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">

View File

@@ -17,12 +17,14 @@ import {
lucideMaximize, lucideMaximize,
lucideMinimize, lucideMinimize,
lucideMonitor, lucideMonitor,
lucideRotateCw,
lucideVideo, lucideVideo,
lucideVolume2, lucideVolume2,
lucideVolumeX lucideVolumeX
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared'; import { UserAvatarComponent } from '../../../../shared';
import { ViewportService } from '../../../../core/platform';
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service'; import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models'; import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
@@ -39,6 +41,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
lucideMaximize, lucideMaximize,
lucideMinimize, lucideMinimize,
lucideMonitor, lucideMonitor,
lucideRotateCw,
lucideVideo, lucideVideo,
lucideVolume2, lucideVolume2,
lucideVolumeX lucideVolumeX
@@ -51,6 +54,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
}) })
export class VoiceWorkspaceStreamTileComponent implements OnDestroy { export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService); private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly viewport = inject(ViewportService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null; private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<VoiceWorkspaceStreamItem>(); readonly item = input.required<VoiceWorkspaceStreamItem>();
@@ -64,6 +68,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo'); readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
readonly isFullscreen = signal(false); readonly isFullscreen = signal(false);
readonly isMobile = this.viewport.isMobile;
readonly showFullscreenHeader = signal(true); readonly showFullscreenHeader = signal(true);
readonly volume = signal(100); readonly volume = signal(100);
readonly muted = signal(false); readonly muted = signal(false);
@@ -138,6 +143,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
return; return;
} }
this.unlockOrientation();
this.clearFullscreenHeaderHideTimeout(); this.clearFullscreenHeaderHideTimeout();
this.showFullscreenHeader.set(true); this.showFullscreenHeader.set(true);
} }
@@ -150,6 +156,8 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
if (tile && document.fullscreenElement === tile) { if (tile && document.fullscreenElement === tile) {
void document.exitFullscreen().catch(() => {}); void document.exitFullscreen().catch(() => {});
} }
this.unlockOrientation();
} }
canToggleFullscreen(): boolean { canToggleFullscreen(): boolean {
@@ -168,22 +176,38 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await this.toggleFullscreen();
}
async toggleFullscreen(event?: Event): Promise<void> {
event?.preventDefault();
event?.stopPropagation();
if (!this.canToggleFullscreen()) { if (!this.canToggleFullscreen()) {
return; return;
} }
const tile = this.tileRef()?.nativeElement; if (this.isFullscreen()) {
if (!tile || !tile.requestFullscreen) {
return;
}
if (document.fullscreenElement === tile) {
await document.exitFullscreen().catch(() => {}); await document.exitFullscreen().catch(() => {});
return; return;
} }
await tile.requestFullscreen().catch(() => {}); await this.enterFullscreen();
}
async enterLandscapeFullscreen(event?: Event): Promise<void> {
event?.preventDefault();
event?.stopPropagation();
if (!this.canToggleFullscreen()) {
return;
}
if (!this.isFullscreen()) {
await this.enterFullscreen();
}
await this.lockLandscape();
} }
async exitFullscreen(event?: Event): Promise<void> { async exitFullscreen(event?: Event): Promise<void> {
@@ -263,6 +287,41 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
: 'Your preview stays muted locally to avoid audio feedback.'; : 'Your preview stays muted locally to avoid audio feedback.';
} }
canControlStreamAudio(): boolean {
const item = this.item();
return !item.isLocal && item.hasAudio;
}
private async enterFullscreen(): Promise<void> {
const tile = this.tileRef()?.nativeElement;
if (tile?.requestFullscreen) {
await tile.requestFullscreen().catch(() => {});
return;
}
const video = this.videoRef()?.nativeElement as WebKitFullscreenVideoElement | undefined;
if (video?.webkitSupportsFullscreen && video.webkitEnterFullscreen) {
video.webkitEnterFullscreen();
}
}
private async lockLandscape(): Promise<void> {
if (!this.isMobile()) {
return;
}
const orientation = screen.orientation as LockableScreenOrientation | undefined;
await orientation?.lock?.('landscape').catch(() => {});
}
private unlockOrientation(): void {
screen.orientation?.unlock?.();
}
private scheduleFullscreenHeaderHide(): void { private scheduleFullscreenHeaderHide(): void {
this.clearFullscreenHeaderHideTimeout(); this.clearFullscreenHeaderHideTimeout();
@@ -286,3 +345,12 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
this.fullscreenHeaderHideTimeoutId = null; this.fullscreenHeaderHideTimeoutId = null;
} }
} }
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
webkitEnterFullscreen?: () => void;
webkitSupportsFullscreen?: boolean;
}
interface LockableScreenOrientation extends ScreenOrientation {
lock?: (orientation: 'landscape') => Promise<void>;
}

View File

@@ -1,21 +1,19 @@
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3"> <nav class="relative flex h-full min-w-14 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
<!-- Create button --> <!-- Create button -->
<button <button
appThemeNode="serversRailCreateButton" appThemeNode="serversRailCreateButton"
type="button" type="button"
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90" class="flex h-11 w-11 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90 md:h-10 md:w-10"
title="Create Server" title="Create Server"
(click)="createServer()" (click)="createServer()"
> >
<ng-icon <ng-icon
name="lucidePlus" name="lucidePlus"
class="w-5 h-5" class="h-[22px] w-[22px] md:h-5 md:w-5"
/> />
</button> </button>
@if (dmRailComponent()) { <app-dm-rail />
<ng-container *ngComponentOutlet="dmRailComponent()" />
}
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) { @for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
<div class="group/call relative flex w-full justify-center"> <div class="group/call relative flex w-full justify-center">
@@ -27,7 +25,7 @@
<button <button
type="button" type="button"
class="relative z-10 grid h-10 w-10 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg" class="relative z-10 grid h-11 w-11 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg md:h-10 md:w-10"
[ngClass]=" [ngClass]="
callAvatarUrls(call).length > 0 callAvatarUrls(call).length > 0
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900' ? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
@@ -61,7 +59,7 @@
<ng-icon <ng-icon
name="lucidePhone" name="lucidePhone"
class="relative z-10 h-5 w-5 drop-shadow" class="relative z-10 h-[22px] w-[22px] drop-shadow md:h-5 md:w-5"
/> />
</button> </button>
</div> </div>
@@ -83,7 +81,7 @@
<button <button
appThemeNode="serversRailItem" appThemeNode="serversRailItem"
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card" class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'" [ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[title]="room.name" [title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null" [attr.aria-current]="isSelectedRoom(room) ? 'page' : null"

View File

@@ -2,7 +2,6 @@
import { import {
Component, Component,
DestroyRef, DestroyRef,
Type,
computed, computed,
effect, effect,
inject, inject,
@@ -36,6 +35,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../infrastructure/persistence';
import { NotificationsFacade } from '../../../domains/notifications'; import { NotificationsFacade } from '../../../domains/notifications';
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call'; import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control'; import { hasRoomBanForUser } from '../../../domains/access-control';
@@ -54,6 +54,7 @@ import {
NgIcon, NgIcon,
ConfirmDialogComponent, ConfirmDialogComponent,
ContextMenuComponent, ContextMenuComponent,
DmRailComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
ThemeNodeDirective, ThemeNodeDirective,
UserBarComponent UserBarComponent
@@ -71,15 +72,16 @@ export class ServersRailComponent {
private serverDirectory = inject(ServerDirectoryFacade); private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
private visibleSavedRoomCache: Room[] = [];
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>(); private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
showMenu = signal(false); showMenu = signal(false);
dmRailComponent = signal<Type<unknown> | null>(null);
menuX = signal(72); menuX = signal(72);
menuY = signal(100); menuY = signal(100);
contextRoom = signal<Room | null>(null); contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -94,9 +96,9 @@ export class ServersRailComponent {
isOnDirectMessage = toSignal( isOnDirectMessage = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm/') || navigationEvent.urlAfterRedirects.startsWith('/pm/')) map((navigationEvent) => this.isDirectMessageUrl(navigationEvent.urlAfterRedirects))
), ),
{ initialValue: this.router.url.startsWith('/dm/') || this.router.url.startsWith('/pm/') } { initialValue: this.isDirectMessageUrl(this.router.url) }
); );
isOnCall = toSignal( isOnCall = toSignal(
this.router.events.pipe( this.router.events.pipe(
@@ -138,7 +140,7 @@ export class ServersRailComponent {
passwordPromptRoom = signal<Room | null>(null); passwordPromptRoom = signal<Room | null>(null);
joinPassword = signal(''); joinPassword = signal('');
joinPasswordError = signal<string | null>(null); joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))); visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
voicePresenceByRoom = computed(() => { voicePresenceByRoom = computed(() => {
const presence: Record<string, number> = {}; const presence: Record<string, number> = {};
const seenByRoom = new Map<string, Set<string>>(); const seenByRoom = new Map<string, Set<string>>();
@@ -181,10 +183,6 @@ export class ServersRailComponent {
}); });
constructor() { constructor() {
void import('../../../domains/direct-message/feature/dm-rail/dm-rail.component').then((module) => {
this.dmRailComponent.set(module.DmRailComponent);
});
effect(() => { effect(() => {
const rooms = this.savedRooms(); const rooms = this.savedRooms();
const currentUser = this.currentUser(); const currentUser = this.currentUser();
@@ -192,6 +190,18 @@ export class ServersRailComponent {
void this.refreshBannedLookup(rooms, currentUser ?? null); void this.refreshBannedLookup(rooms, currentUser ?? null);
}); });
effect(() => {
const optimisticRoomId = this.optimisticSelectedRoomId();
if (!optimisticRoomId) {
return;
}
if (this.currentRoom()?.id === optimisticRoomId && !this.isOnDirectMessage() && !this.isOnCall()) {
this.optimisticSelectedRoomId.set(null);
}
});
this.savedRoomJoinRequests this.savedRoomJoinRequests
.pipe( .pipe(
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)), switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
@@ -214,6 +224,8 @@ export class ServersRailComponent {
createServer(): void { createServer(): void {
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
if (voiceServerId) { if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false); this.voiceSession.setViewingVoiceServer(false);
} }
@@ -222,6 +234,7 @@ export class ServersRailComponent {
} }
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
@@ -229,18 +242,20 @@ export class ServersRailComponent {
return; return;
} }
if (this.isRoomMarkedBanned(room)) { if (this.isRoomMarkedBanned(targetRoom)) {
this.bannedServerName.set(room.name); this.bannedServerName.set(targetRoom.name);
this.showBannedDialog.set(true); this.showBannedDialog.set(true);
return; return;
} }
this.activateSavedRoom(room); this.optimisticSelectedRoomId.set(targetRoom.id);
this.savedRoomJoinRequests.next({ room }); this.activateSavedRoom(targetRoom);
this.savedRoomJoinRequests.next({ room: targetRoom });
} }
openCall(callId: string): void { openCall(callId: string): void {
void this.router.navigate(['/call', callId]); this.optimisticSelectedRoomId.set(null);
void this.directCalls.openCallView(callId);
} }
isSelectedCall(callIndex: number): boolean { isSelectedCall(callIndex: number): boolean {
@@ -335,6 +350,7 @@ export class ServersRailComponent {
); );
if (isCurrentRoom) { if (isCurrentRoom) {
this.optimisticSelectedRoomId.set(null);
this.router.navigate(['/search']); this.router.navigate(['/search']);
} }
@@ -378,9 +394,44 @@ export class ServersRailComponent {
return false; return false;
} }
const optimisticRoomId = this.optimisticSelectedRoomId();
if (optimisticRoomId) {
return optimisticRoomId === room.id;
}
return this.currentRoom()?.id === room.id; return this.currentRoom()?.id === room.id;
} }
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
const stabilizedRooms = nextRooms.map((room) => {
const previousRoom = previousById.get(room.id);
return previousRoom && this.hasSameRailRoomView(previousRoom, room) ? previousRoom : room;
});
if (
stabilizedRooms.length === this.visibleSavedRoomCache.length
&& stabilizedRooms.every((room, index) => room === this.visibleSavedRoomCache[index])
) {
return this.visibleSavedRoomCache;
}
this.visibleSavedRoomCache = stabilizedRooms;
return stabilizedRooms;
}
private hasSameRailRoomView(previousRoom: Room, nextRoom: Room): boolean {
return previousRoom.id === nextRoom.id && previousRoom.name === nextRoom.name && previousRoom.icon === nextRoom.icon;
}
private isDirectMessageUrl(url: string): boolean {
const path = url.split(/[?#]/, 1)[0];
return path === '/dm' || path.startsWith('/dm/') || path === '/pm' || path.startsWith('/pm/');
}
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> { private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion; const requestVersion = ++this.banLookupRequestVersion;
@@ -492,6 +543,7 @@ export class ServersRailComponent {
if (errorCode === 'BANNED') { if (errorCode === 'BANNED') {
this.closePasswordDialog(); this.closePasswordDialog();
this.optimisticSelectedRoomId.set(null);
this.bannedRoomLookup.update((lookup) => ({ this.bannedRoomLookup.update((lookup) => ({
...lookup, ...lookup,
[room.id]: true [room.id]: true

View File

@@ -57,7 +57,7 @@ The persisted `rooms` store is a local cache of room metadata. Channel topology
### Browser (IndexedDB) ### Browser (IndexedDB)
All operations run inside IndexedDB transactions in the renderer thread. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string). All operations run inside IndexedDB transactions in the renderer thread. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. Queries like `getMessages` pull all messages for a room via the `roomId` index, optionally filter to a text channel, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -66,11 +66,11 @@ sequenceDiagram
participant BDB as BrowserDatabaseService participant BDB as BrowserDatabaseService
participant IDB as IndexedDB participant IDB as IndexedDB
Eff->>DB: getMessages(roomId, 50) Eff->>DB: getMessages(roomId, 50, 0, channelId?)
DB->>BDB: getMessages(roomId, 50) DB->>BDB: getMessages(roomId, 50, 0, channelId?)
BDB->>IDB: tx.objectStore("messages")<br/>.index("roomId").getAll(roomId) BDB->>IDB: tx.objectStore("messages")<br/>.index("roomId").getAll(roomId)
IDB-->>BDB: Message[] IDB-->>BDB: Message[]
Note over BDB: Sort by timestamp, slice, normalise Note over BDB: Optional channel filter, sort, slice, normalise
BDB-->>DB: Message[] BDB-->>DB: Message[]
DB-->>Eff: Message[] DB-->>Eff: Message[]
``` ```

View File

@@ -66,31 +66,48 @@ export class BrowserDatabaseService {
} }
/** /**
* Retrieve messages for a room, sorted oldest-first. * Retrieve the latest messages for a room, sorted oldest-first for display.
* @param roomId - Target room. * @param roomId - Target room.
* @param limit - Maximum number of messages to return. * @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination). * @param offset - Number of newer messages to skip (for pagination).
* @param channelId - Optional channel scope; 'general' includes null/empty.
* @param beforeTimestamp - Optional cursor; only messages strictly older
* than this timestamp are returned. Used for
* scroll-up history pagination.
*/ */
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> { async getMessages(
roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>( const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId STORE_MESSAGES, 'roomId', roomId
); );
const scopedMessages = channelId
? allRoomMessages.filter((message) => (message.channelId || 'general') === channelId)
: allRoomMessages;
const cursorFiltered = beforeTimestamp === undefined
? scopedMessages
: scopedMessages.filter((message) => message.timestamp < beforeTimestamp);
const sortedMessages = cursorFiltered.sort((first, second) => first.timestamp - second.timestamp);
const endIndex = Math.max(sortedMessages.length - offset, 0);
const startIndex = Math.max(endIndex - limit, 0);
const messages = sortedMessages.slice(startIndex, endIndex);
return allRoomMessages return this.hydrateMessages(messages);
.sort((first, second) => first.timestamp - second.timestamp)
.slice(offset, offset + limit)
.map((message) => this.normaliseMessage(message));
} }
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> { async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>( const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId STORE_MESSAGES, 'roomId', roomId
); );
const messages = allRoomMessages
return allRoomMessages
.filter((message) => message.timestamp > sinceTimestamp) .filter((message) => message.timestamp > sinceTimestamp)
.sort((first, second) => first.timestamp - second.timestamp) .sort((first, second) => first.timestamp - second.timestamp);
.map((message) => this.normaliseMessage(message));
return this.hydrateMessages(messages);
} }
/** Delete a message by its ID. */ /** Delete a message by its ID. */
@@ -112,7 +129,11 @@ export class BrowserDatabaseService {
async getMessageById(messageId: string): Promise<Message | null> { async getMessageById(messageId: string): Promise<Message | null> {
const message = await this.get<Message>(STORE_MESSAGES, messageId); const message = await this.get<Message>(STORE_MESSAGES, messageId);
return message ? this.normaliseMessage(message) : null; if (!message) {
return null;
}
return (await this.hydrateMessages([message]))[0] ?? null;
} }
/** Remove every message belonging to a room. */ /** Remove every message belonging to a room. */
@@ -520,6 +541,47 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
if (messages.length === 0) {
return [];
}
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
return messages.map((message) => this.normaliseMessage({
...message,
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
}));
}
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
const reactionsByMessageId = new Map<string, Reaction[]>();
if (messageIdSet.size === 0) {
return reactionsByMessageId;
}
const allReactions = await this.getAll<Reaction>(STORE_REACTIONS);
for (const reaction of allReactions) {
if (!messageIdSet.has(reaction.messageId)) {
continue;
}
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
reactions.push(reaction);
reactionsByMessageId.set(reaction.messageId, reactions);
}
for (const reactions of reactionsByMessageId.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return reactionsByMessageId;
}
private normaliseMessage(message: Message): Message { private normaliseMessage(message: Message): Message {
if (message.content === DELETED_MESSAGE_CONTENT) { if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message, return { ...message,

View File

@@ -49,8 +49,19 @@ export class DatabaseService {
/** Persist a single chat message. */ /** Persist a single chat message. */
saveMessage(message: Message) { return this.backend.saveMessage(message); } saveMessage(message: Message) { return this.backend.saveMessage(message); }
/** Retrieve messages for a room with optional pagination. */ /** Retrieve the latest messages for a room or channel with optional pagination.
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); } *
* When `beforeTimestamp` is provided, only messages strictly older than that
* timestamp are returned. This is how scroll-up history loading paginates
* backwards through the DB without holding the whole history in memory.
*/
getMessages(
roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
) { return this.backend.getMessages(roomId, limit, offset, channelId, beforeTimestamp); }
/** Retrieve messages newer than a given timestamp for a room. */ /** Retrieve messages newer than a given timestamp for a room. */
getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); } getMessagesSince(roomId: string, sinceTimestamp: number) { return this.backend.getMessagesSince(roomId, sinceTimestamp); }

View File

@@ -37,14 +37,26 @@ export class ElectronDatabaseService {
} }
/** /**
* Retrieve messages for a room, sorted oldest-first. * Retrieve the latest messages for a room, sorted oldest-first for display.
* *
* @param roomId - Target room. * @param roomId - Target room.
* @param limit - Maximum number of messages to return. * @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination). * @param offset - Number of newer messages to skip (for pagination).
* @param channelId - Optional channel scope; 'general' includes null/empty.
* @param beforeTimestamp - Optional cursor; only messages strictly older
* than this timestamp are returned (scroll-up paging).
*/ */
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> { getMessages(
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } }); roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
): Promise<Message[]> {
return this.api.query<Message[]>({
type: 'get-messages',
payload: { roomId, limit, offset, channelId, beforeTimestamp }
});
} }
getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> { getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {

View File

@@ -250,7 +250,7 @@ Profile avatar sync follows attachment-style chunk transport plus server-icon-st
Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal. Every 5 seconds a PING message is sent to each peer. The peer responds with PONG carrying the original timestamp, and the round-trip latency is stored in a signal.
Data-channel failures are treated as control-plane failures, not proof that RTP audio has stopped. When an open channel reports a non-fatal error, the client requests a fresh voice-state snapshot over that same channel. When the channel closes or cannot carry the resync request, the peer manager waits a short grace period so any still-flowing audio is not interrupted by a transient event. If the `RTCPeerConnection` is still connected after that grace period, the elected initiator replaces only the data channel in-place and preserves the media transport. Full peer recreation is reserved for cases where the media transport is no longer connected or the in-place control-channel repair fails. Data-channel failures are treated as control-plane failures. When an open channel reports a non-fatal error, the client requests a fresh voice-state snapshot over that same channel. When the channel is already closed there is no recovery on it, so the peer manager acts immediately: the deterministic initiator renegotiates a new `RTCDataChannel` on the existing `RTCPeerConnection` (preserving audio/video transport), the non-initiator briefly waits for that replacement and then forces a full peer rebuild if it does not arrive, and a peer whose `RTCPeerConnection` is no longer in `connected` state is recreated immediately through the normal deterministic reconnect path. A closing-but-not-yet-closed channel still waits a short grace period in case the underlying transport flips back. Either way, the rebuild heals chat, state sync, voice, camera, and screen-share transport together instead of preserving a media connection whose control channel can no longer coordinate peer state.
## Media pipeline ## Media pipeline

View File

@@ -13,7 +13,7 @@ describe('peer recovery', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it('waits a short grace period before replacing a closed data channel in place', () => { it('recreates a peer immediately when the data channel is already closed', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const channel = createDataChannel('closed'); const channel = createDataChannel('closed');
@@ -24,29 +24,28 @@ describe('peer recovery', () => {
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true);
expect(handlers.createAndSendOffer).toHaveBeenCalledWith('bob');
expect(context.state.dataChannelRecoveryTimers.has('bob')).toBe(false);
});
it('waits a short grace period before recreating a peer with a closing data channel', () => {
vi.useFakeTimers();
const channel = createDataChannel('closing');
const context = createContext('alice');
const handlers = createRecoveryHandlers(context);
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS - 1); vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS - 1);
expect(handlers.removePeer).not.toHaveBeenCalled(); expect(handlers.removePeer).not.toHaveBeenCalled();
expect(handlers.createPeerConnection).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).not.toHaveBeenCalled();
vi.advanceTimersByTime(1); vi.advanceTimersByTime(1);
expect(handlers.replaceDataChannel).toHaveBeenCalledWith('bob', channel);
expect(handlers.removePeer).not.toHaveBeenCalled();
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
});
it('falls back to full peer recreation when in-place data channel replacement fails', () => {
vi.useFakeTimers();
const channel = createDataChannel('closed');
const context = createContext('alice');
const handlers = createRecoveryHandlers(context);
handlers.replaceDataChannel.mockReturnValueOnce(false);
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true }); expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', true);
@@ -56,7 +55,7 @@ describe('peer recovery', () => {
it('does not recreate a peer when a replacement data channel is adopted before the grace expires', () => { it('does not recreate a peer when a replacement data channel is adopted before the grace expires', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const staleChannel = createDataChannel('closed'); const staleChannel = createDataChannel('closing');
const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN); const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
const context = createContext('alice'); const context = createContext('alice');
const handlers = createRecoveryHandlers(context); const handlers = createRecoveryHandlers(context);
@@ -90,7 +89,7 @@ describe('peer recovery', () => {
expect(handlers.createPeerConnection).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).not.toHaveBeenCalled();
}); });
it('preserves a connected non-initiator peer while waiting for the remote initiator to replace the channel', () => { it('recreates a connected non-initiator peer and waits for the remote initiator offer', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const channel = createDataChannel('closed'); const channel = createDataChannel('closed');
@@ -99,11 +98,10 @@ describe('peer recovery', () => {
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false)); context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false));
scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers); scheduleDataChannelRecovery(context, 'bob', channel, 'close', handlers);
vi.advanceTimersByTime(DATA_CHANNEL_RECOVERY_GRACE_MS);
expect(handlers.removePeer).not.toHaveBeenCalled(); expect(handlers.removePeer).toHaveBeenCalledWith('bob', { preserveReconnectState: true });
expect(handlers.replaceDataChannel).not.toHaveBeenCalled(); expect(handlers.replaceDataChannel).not.toHaveBeenCalled();
expect(handlers.createPeerConnection).not.toHaveBeenCalled(); expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false);
expect(handlers.createAndSendOffer).not.toHaveBeenCalled(); expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
}); });

View File

@@ -154,6 +154,18 @@ export function scheduleDataChannelRecovery(
if (channel.readyState === DATA_CHANNEL_STATE_OPEN) if (channel.readyState === DATA_CHANNEL_STATE_OPEN)
return; return;
if (channel.readyState === 'closed') {
logger.warn('[data-channel] Control channel closed; reconnecting peer immediately', {
channelLabel: channel.label,
connectionState: peerData.connection.connectionState,
peerId,
reason
});
repairUnavailableDataChannel(context, peerId, channel, reason, handlers);
return;
}
if (state.dataChannelRecoveryTimers.has(peerId)) if (state.dataChannelRecoveryTimers.has(peerId))
return; return;
@@ -183,35 +195,42 @@ export function scheduleDataChannelRecovery(
reason reason
}); });
if (latestPeerData.connection.connectionState === CONNECTION_STATE_CONNECTED) { repairUnavailableDataChannel(context, peerId, channel, reason, handlers);
if (latestPeerData.isInitiator && handlers.replaceDataChannel(peerId, channel)) {
logger.info('[data-channel] Replaced control channel without recreating media transport', {
peerId,
reason
});
return;
}
if (!latestPeerData.isInitiator) {
logger.info('[data-channel] Waiting for initiator to replace control channel; preserving media transport', {
peerId,
reason
});
return;
}
}
trackDisconnectedPeer(state, peerId);
handlers.removePeer(peerId, { preserveReconnectState: true });
attemptPeerReconnect(context, peerId, handlers);
schedulePeerReconnect(context, peerId, handlers);
}, DATA_CHANNEL_RECOVERY_GRACE_MS); }, DATA_CHANNEL_RECOVERY_GRACE_MS);
state.dataChannelRecoveryTimers.set(peerId, timer); state.dataChannelRecoveryTimers.set(peerId, timer);
} }
function repairUnavailableDataChannel(
context: PeerConnectionManagerContext,
peerId: string,
channel: RTCDataChannel,
reason: string,
handlers: RecoveryHandlers
): void {
const { logger, state } = context;
const peerData = state.activePeerConnections.get(peerId);
if (!peerData || peerData.dataChannel !== channel)
return;
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN)
return;
logger.warn('[data-channel] Recreating peer transport after control channel failure', {
channelLabel: channel.label,
connectionState: peerData.connection.connectionState,
peerId,
readyState: peerData.dataChannel?.readyState ?? null,
reason
});
trackDisconnectedPeer(state, peerId);
handlers.removePeer(peerId, { preserveReconnectState: true });
attemptPeerReconnect(context, peerId, handlers);
schedulePeerReconnect(context, peerId, handlers);
}
export function schedulePeerDisconnectRecovery( export function schedulePeerDisconnectRecovery(
context: PeerConnectionManagerContext, context: PeerConnectionManagerContext,
peerId: string, peerId: string,

View File

@@ -15,7 +15,7 @@
class="h-24 bg-gradient-to-r from-primary/30 to-primary/10" class="h-24 bg-gradient-to-r from-primary/30 to-primary/10"
></div> ></div>
<div class="-mt-12 flex flex-col items-center px-6"> <div class="-mt-16 flex flex-col items-center px-6">
<div class="relative"> <div class="relative">
<button <button
type="button" type="button"
@@ -26,7 +26,7 @@
<app-user-avatar <app-user-avatar
[name]="profileUser.displayName" [name]="profileUser.displayName"
[avatarUrl]="profileUser.avatarUrl" [avatarUrl]="profileUser.avatarUrl"
size="xl" size="2xl"
[status]="profileUser.status" [status]="profileUser.status"
[showStatusBadge]="true" [showStatusBadge]="true"
ringClass="ring-4 ring-card" ringClass="ring-4 ring-card"
@@ -34,11 +34,11 @@
</button> </button>
@if (isEditable) { @if (isEditable) {
<span <span
class="pointer-events-none absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow" class="pointer-events-none absolute bottom-2 right-2 flex h-9 w-9 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow"
> >
<ng-icon <ng-icon
name="lucideCamera" name="lucideCamera"
class="h-3.5 w-3.5" class="h-4 w-4"
/> />
</span> </span>
} }

View File

@@ -16,29 +16,42 @@ import { UserStatus } from '../../../shared-kernel';
export class UserAvatarComponent { export class UserAvatarComponent {
name = input.required<string>(); name = input.required<string>();
avatarUrl = input<string | undefined | null>(); avatarUrl = input<string | undefined | null>();
size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl'>('sm'); size = input<'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'>('sm');
ringClass = input<string>(''); ringClass = input<string>('');
status = input<UserStatus | undefined>(); status = input<UserStatus | undefined>();
showStatusBadge = input(false); showStatusBadge = input(false);
statusBadgeColor = computed(() => { statusBadgeColor = computed(() => {
switch (this.status()) { switch (this.status()) {
case 'online': return 'bg-green-500'; case 'online':
case 'away': return 'bg-yellow-500'; return 'bg-green-500';
case 'busy': return 'bg-red-500'; case 'away':
case 'offline': return 'bg-gray-500'; return 'bg-yellow-500';
case 'disconnected': return 'bg-gray-500'; case 'busy':
default: return 'bg-gray-500'; return 'bg-red-500';
case 'offline':
return 'bg-gray-500';
case 'disconnected':
return 'bg-gray-500';
default:
return 'bg-gray-500';
} }
}); });
statusBadgeSizeClass = computed(() => { statusBadgeSizeClass = computed(() => {
switch (this.size()) { switch (this.size()) {
case 'xs': return 'w-2 h-2'; case 'xs':
case 'sm': return 'w-3 h-3'; return 'w-2 h-2';
case 'md': return 'w-3.5 h-3.5'; case 'sm':
case 'lg': return 'w-4 h-4'; return 'w-3 h-3';
case 'xl': return 'w-4.5 h-4.5'; case 'md':
return 'w-3.5 h-3.5';
case 'lg':
return 'w-4 h-4';
case 'xl':
return 'w-4.5 h-4.5';
case '2xl':
return 'w-6 h-6';
} }
}); });
@@ -49,31 +62,52 @@ export class UserAvatarComponent {
sizeClasses(): string { sizeClasses(): string {
switch (this.size()) { switch (this.size()) {
case 'xs': return 'w-7 h-7'; case 'xs':
case 'sm': return 'w-8 h-8'; return 'w-7 h-7';
case 'md': return 'w-10 h-10'; case 'sm':
case 'lg': return 'w-12 h-12'; return 'w-8 h-8';
case 'xl': return 'w-16 h-16'; case 'md':
return 'w-10 h-10';
case 'lg':
return 'w-12 h-12';
case 'xl':
return 'w-16 h-16';
case '2xl':
return 'w-32 h-32';
} }
} }
sizePx(): number { sizePx(): number {
switch (this.size()) { switch (this.size()) {
case 'xs': return 28; case 'xs':
case 'sm': return 32; return 28;
case 'md': return 40; case 'sm':
case 'lg': return 48; return 32;
case 'xl': return 64; case 'md':
return 40;
case 'lg':
return 48;
case 'xl':
return 64;
case '2xl':
return 128;
} }
} }
textClass(): string { textClass(): string {
switch (this.size()) { switch (this.size()) {
case 'xs': return 'text-xs'; case 'xs':
case 'sm': return 'text-sm'; return 'text-xs';
case 'md': return 'text-base font-semibold'; case 'sm':
case 'lg': return 'text-lg font-semibold'; return 'text-sm';
case 'xl': return 'text-xl font-semibold'; case 'md':
return 'text-base font-semibold';
case 'lg':
return 'text-lg font-semibold';
case 'xl':
return 'text-xl font-semibold';
case '2xl':
return 'text-4xl font-semibold';
} }
} }
} }

View File

@@ -95,7 +95,7 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0); expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0);
expect(sendToPeer).toHaveBeenCalledWith('peer-2', { expect(sendToPeer).toHaveBeenCalledWith('peer-2', {
type: 'chat-sync-full', type: 'chat-sync-batch',
roomId: 'room-b', roomId: 'room-b',
messages: roomBMessages messages: roomBMessages
}); });

View File

@@ -289,6 +289,12 @@ async function processSyncBatch(
attachments: AttachmentFacade attachments: AttachmentFacade
): Promise<Message[]> { ): Promise<Message[]> {
const toUpsert: Message[] = []; const toUpsert: Message[] = [];
// Yield to the event loop every YIELD_EVERY messages so Angular change
// detection and user input aren't starved while a large sync batch
// (e.g. from a bulk plugin import) drains serial DB writes.
const YIELD_EVERY = 50;
let processed = 0;
for (const incoming of event.messages) { for (const incoming of event.messages) {
attachments.rememberMessageRoom(incoming.id, incoming.roomId); attachments.rememberMessageRoom(incoming.id, incoming.roomId);
@@ -305,6 +311,12 @@ async function processSyncBatch(
if (changed) if (changed)
toUpsert.push(message); toUpsert.push(message);
processed += 1;
if (processed % YIELD_EVERY === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
} }
if (hasAttachmentMetaMap(event.attachments)) { if (hasAttachmentMetaMap(event.attachments)) {
@@ -603,13 +615,20 @@ function handleSyncRequest(
return from( return from(
(async () => { (async () => {
const all = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0); const all = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0);
const syncFullEvent: ChatEvent = {
type: 'chat-sync-full',
roomId: targetRoomId,
messages: all
};
webrtc.sendToPeer(fromPeerId, syncFullEvent); // Ship as chunked chat-sync-batch events instead of a single
// chat-sync-full payload. A monolithic dump of up to FULL_SYNC_LIMIT
// messages can exceed the WebRTC SCTP per-message size ceiling and be
// silently dropped - especially after bulk plugin imports.
for (const chunk of chunkArray(all, CHUNK_SIZE)) {
const syncBatchEvent: ChatEvent = {
type: 'chat-sync-batch',
roomId: targetRoomId,
messages: chunk
};
webrtc.sendToPeer(fromPeerId, syncBatchEvent);
}
})() })()
).pipe(mergeMap(() => EMPTY)); ).pipe(mergeMap(() => EMPTY));
} }

View File

@@ -111,40 +111,47 @@ export class MessagesSyncEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => { switchMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room; const requestedRoomId = room.id;
if (!activeRoom) return timer(75).pipe(
return EMPTY; withLatestFrom(this.store.select(selectCurrentRoom)),
switchMap(([, latestCurrentRoom]) => {
const activeRoom = latestCurrentRoom ?? currentRoom ?? room;
const peers = this.webrtc.getConnectedPeers();
return from( if (!activeRoom || activeRoom.id !== requestedRoomId || peers.length === 0) {
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0) return EMPTY;
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
for (const pid of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary',
roomId: activeRoom.id,
count,
lastUpdated
});
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: activeRoom.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
error,
peerId: pid,
roomId: activeRoom.id
});
}
} }
return from(this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary',
roomId: activeRoom.id,
count,
lastUpdated
});
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: activeRoom.id
});
} catch (error) {
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
error,
peerId: pid,
roomId: activeRoom.id
});
}
}
})
);
}) })
); );
}) })

View File

@@ -23,6 +23,24 @@ export const MessagesActions = createActionGroup({
'Load Messages Success': props<{ messages: Message[] }>(), 'Load Messages Success': props<{ messages: Message[] }>(),
'Load Messages Failure': props<{ error: string }>(), 'Load Messages Failure': props<{ error: string }>(),
/**
* Fetches a page of messages strictly older than `beforeTimestamp` for a
* given conversation (room + channel). Used by the chat scroll-up handler
* to backfill history from the local database on demand.
*/
'Load Older Messages': props<{
roomId: string;
channelId: string;
beforeTimestamp: number;
limit: number;
}>(),
'Load Older Messages Success': props<{
conversationKey: string;
messages: Message[];
reachedEnd: boolean;
}>(),
'Load Older Messages Failure': props<{ error: string }>(),
/** Sends a new chat message to the current room and broadcasts to peers. */ /** Sends a new chat message to the current room and broadcasts to peers. */
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(), 'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(),
'Send Message Success': props<{ message: Message }>(), 'Send Message Success': props<{ message: Message }>(),

View File

@@ -43,13 +43,16 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
import { import {
DELETED_MESSAGE_CONTENT, DELETED_MESSAGE_CONTENT,
Message, Message,
Reaction Reaction,
Room
} from '../../shared-kernel'; } from '../../shared-kernel';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules'; import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomPermission } from '../../domains/access-control'; import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
@Injectable() @Injectable()
export class MessagesEffects { export class MessagesEffects {
private readonly actions$ = inject(Actions); private readonly actions$ = inject(Actions);
@@ -65,8 +68,9 @@ export class MessagesEffects {
loadMessages$ = createEffect(() => loadMessages$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.loadMessages), ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) => withLatestFrom(this.store.select(selectCurrentRoom)),
from(this.db.getMessages(roomId)).pipe( switchMap(([{ roomId }, currentRoom]) =>
from(this.loadInitialMessages(roomId, currentRoom)).pipe(
mergeMap(async (messages) => { mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db); const hydrated = await hydrateMessages(messages, this.db);
@@ -86,6 +90,58 @@ export class MessagesEffects {
) )
); );
/** Paginates older messages from the local DB for scroll-up history loading. */
loadOlderMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.loadOlderMessages),
mergeMap(({ roomId, channelId, beforeTimestamp, limit }) =>
from(
this.db.getMessages(roomId, limit, 0, channelId, beforeTimestamp)
).pipe(
mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db);
for (const message of hydrated) {
this.attachments.rememberMessageRoom(message.id, message.roomId);
}
return MessagesActions.loadOlderMessagesSuccess({
conversationKey: `${roomId}:${channelId}`,
messages: hydrated,
reachedEnd: hydrated.length < limit
});
}),
catchError((error) =>
of(MessagesActions.loadOlderMessagesFailure({ error: error.message }))
)
)
)
)
);
private async loadInitialMessages(roomId: string, currentRoom: Room | null): Promise<Message[]> {
const textChannels = currentRoom?.id === roomId
? (currentRoom.channels ?? []).filter((channel) => channel.type === 'text')
: [];
if (textChannels.length <= 1) {
return this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, textChannels[0]?.id);
}
const channelMessageSets = await Promise.all(
textChannels.map((channel) => this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0, channel.id))
);
const messagesById = new Map<string, Message>();
for (const messages of channelMessageSets) {
for (const message of messages) {
messagesById.set(message.id, message);
}
}
return [...messagesById.values()].sort((first, second) => first.timestamp - second.timestamp);
}
/** Constructs a new message, persists it locally, and broadcasts to all peers. */ /** Constructs a new message, persists it locally, and broadcasts to all peers. */
sendMessage$ = createEffect(() => sendMessage$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(

View File

@@ -29,29 +29,33 @@ export type { InventoryItem } from '../../domains/chat/domain/rules/message-sync
/** Hydrates a single message with its reactions from the database. */ /** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage( export async function hydrateMessage(
msg: Message, msg: Message,
db: DatabaseService _db: DatabaseService
): Promise<Message> { ): Promise<Message> {
if (msg.isDeleted) if (msg.isDeleted)
return normaliseDeletedMessage(msg); return normaliseDeletedMessage(msg);
const reactions = await db.getReactionsForMessage(msg.id); return msg;
return reactions.length > 0 ? { ...msg,
reactions } : msg;
} }
/** Hydrates an array of messages with their reactions. */ /** Hydrates an array of messages with their reactions. */
export async function hydrateMessages( export async function hydrateMessages(
messages: Message[], messages: Message[],
db: DatabaseService _db: DatabaseService
): Promise<Message[]> { ): Promise<Message[]> {
return Promise.all(messages.map((msg) => hydrateMessage(msg, db))); return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg);
} }
/** Builds a sync inventory item from a message and its reaction count. */ /** Builds a sync inventory item from a message and its reaction count.
*
* Reactions are read from the already-hydrated `msg.reactions` array (the
* persistence layer joins them in via `getMessages`), and attachment counts
* only come from the in-memory override. We deliberately avoid per-message
* DB lookups here so a whole-room inventory stays O(1) DB calls even when
* the room contains tens of thousands of messages.
*/
export async function buildInventoryItem( export async function buildInventoryItem(
msg: Message, msg: Message,
db: DatabaseService, _db: DatabaseService,
attachmentCountOverride?: number attachmentCountOverride?: number
): Promise<InventoryItem> { ): Promise<InventoryItem> {
if (msg.isDeleted) { if (msg.isDeleted) {
@@ -63,50 +67,49 @@ export async function buildInventoryItem(
}; };
} }
const reactions = await db.getReactionsForMessage(msg.id); const item: InventoryItem = {
const attachments = id: msg.id,
attachmentCountOverride === undefined
? await db.getAttachmentsForMessage(msg.id)
: [];
return { id: msg.id,
ts: getMessageTimestamp(msg), ts: getMessageTimestamp(msg),
rc: reactions.length, rc: msg.reactions?.length ?? 0
ac: attachmentCountOverride ?? attachments.length }; };
if (attachmentCountOverride !== undefined) {
item.ac = attachmentCountOverride;
}
return item;
} }
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */ /** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID.
*
* As with {@link buildInventoryItem}, reactions come from the already-hydrated
* `msg.reactions` array and attachment counts only come from the in-memory
* override map.
*/
export async function buildLocalInventoryMap( export async function buildLocalInventoryMap(
messages: Message[], messages: Message[],
db: DatabaseService, _db: DatabaseService,
attachmentCountOverrides?: ReadonlyMap<string, number> attachmentCountOverrides?: ReadonlyMap<string, number>
): Promise<Map<string, { ts: number; rc: number; ac: number }>> { ): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
const map = new Map<string, { ts: number; rc: number; ac: number }>(); const map = new Map<string, { ts: number; rc: number; ac: number }>();
await Promise.all( for (const msg of messages) {
messages.map(async (msg) => { if (msg.isDeleted) {
if (msg.isDeleted) { map.set(msg.id, {
map.set(msg.id, { ts: getMessageTimestamp(msg),
ts: getMessageTimestamp(msg), rc: 0,
rc: 0, ac: 0
ac: 0 });
});
return; continue;
} }
const reactions = await db.getReactionsForMessage(msg.id); map.set(msg.id, {
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id); ts: getMessageTimestamp(msg),
const attachments = rc: msg.reactions?.length ?? 0,
attachmentCountOverride === undefined ac: attachmentCountOverrides?.get(msg.id) ?? 0
? await db.getAttachmentsForMessage(msg.id) });
: []; }
map.set(msg.id, { ts: getMessageTimestamp(msg),
rc: reactions.length,
ac: attachmentCountOverride ?? attachments.length });
})
);
return map; return map;
} }

View File

@@ -13,10 +13,18 @@ export interface MessagesState extends EntityState<Message> {
loading: boolean; loading: boolean;
/** Whether a peer-to-peer sync cycle is in progress. */ /** Whether a peer-to-peer sync cycle is in progress. */
syncing: boolean; syncing: boolean;
/** Whether a scroll-up older-page fetch is currently in flight. */
loadingOlder: boolean;
/** Most recent error message from message operations. */ /** Most recent error message from message operations. */
error: string | null; error: string | null;
/** ID of the room whose messages are currently loaded. */ /** ID of the room whose messages are currently loaded. */
currentRoomId: string | null; currentRoomId: string | null;
/**
* Conversation keys (`${roomId}:${channelId}`) that have been paginated
* all the way back to the start of the local DB history. Used by the
* scroll-up handler to stop issuing further DB pages.
*/
exhaustedConversations: Record<string, true>;
} }
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
@@ -27,8 +35,10 @@ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Messa
export const initialState: MessagesState = messagesAdapter.getInitialState({ export const initialState: MessagesState = messagesAdapter.getInitialState({
loading: false, loading: false,
syncing: false, syncing: false,
loadingOlder: false,
error: null, error: null,
currentRoomId: null currentRoomId: null,
exhaustedConversations: {}
}); });
export const messagesReducer = createReducer( export const messagesReducer = createReducer(
@@ -41,7 +51,8 @@ export const messagesReducer = createReducer(
...state, ...state,
loading: true, loading: true,
error: null, error: null,
currentRoomId: roomId currentRoomId: roomId,
exhaustedConversations: {}
}); });
} }
@@ -66,6 +77,30 @@ export const messagesReducer = createReducer(
error error
})), })),
// Load older messages - paginate backwards from the DB on scroll-up.
on(MessagesActions.loadOlderMessages, (state) => ({
...state,
loadingOlder: true,
error: null
})),
on(MessagesActions.loadOlderMessagesSuccess, (state, { conversationKey, messages, reachedEnd }) =>
messagesAdapter.upsertMany(messages, {
...state,
loadingOlder: false,
exhaustedConversations: reachedEnd
? { ...state.exhaustedConversations,
[conversationKey]: true }
: state.exhaustedConversations
})
),
on(MessagesActions.loadOlderMessagesFailure, (state, { error }) => ({
...state,
loadingOlder: false,
error
})),
// Send message // Send message
on(MessagesActions.sendMessage, (state) => ({ on(MessagesActions.sendMessage, (state) => ({
...state, ...state,
@@ -202,7 +237,10 @@ export const messagesReducer = createReducer(
return messagesAdapter.upsertMany(merged, { return messagesAdapter.upsertMany(merged, {
...state, ...state,
syncing: false syncing: false,
// Peer sync may have inserted messages older than our current oldest;
// reopen pagination so the scroll-up handler revisits the DB.
exhaustedConversations: {}
}); });
}), }),
@@ -221,7 +259,8 @@ export const messagesReducer = createReducer(
on(MessagesActions.clearMessages, (state) => on(MessagesActions.clearMessages, (state) =>
messagesAdapter.removeAll({ messagesAdapter.removeAll({
...state, ...state,
currentRoomId: null currentRoomId: null,
exhaustedConversations: {}
}) })
) )
); );

View File

@@ -36,6 +36,21 @@ export const selectMessagesSyncing = createSelector(
(state) => state.syncing (state) => state.syncing
); );
/** Whether a scroll-up older-page DB fetch is currently in flight. */
export const selectMessagesLoadingOlder = createSelector(
selectMessagesState,
(state) => state.loadingOlder
);
/** Whether the given conversation (`${roomId}:${channelId}`) has been
* paginated all the way back to the start of the local DB history.
*/
export const selectConversationExhausted = (conversationKey: string) =>
createSelector(
selectMessagesState,
(state) => state.exhaustedConversations[conversationKey] === true
);
/** Selects the ID of the room whose messages are currently loaded. */ /** Selects the ID of the room whose messages are currently loaded. */
export const selectCurrentRoomId = createSelector( export const selectCurrentRoomId = createSelector(
selectMessagesState, selectMessagesState,

View File

@@ -340,11 +340,12 @@ export class RoomMembersSyncEffects {
const role = room.hostId === currentUser.id const role = room.hostId === currentUser.id
? 'host' ? 'host'
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member'); : (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
return { return {
...roomMemberFromUser(currentUser, Date.now(), role), ...roomMemberFromUser(currentUser, seenAt, role),
id: existingMember?.id ?? currentUser.id, id: existingMember?.id ?? currentUser.id,
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(), joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl, avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
role role
}; };

View File

@@ -12,7 +12,8 @@ import {
of, of,
from, from,
EMPTY, EMPTY,
merge merge,
timer
} from 'rxjs'; } from 'rxjs';
import { import {
map, map,
@@ -60,6 +61,8 @@ type BlockedRoomAccessAction =
| ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>; | ReturnType<typeof RoomsActions.joinRoomFailure>;
const VIEW_SERVER_LOAD_DELAY_MS = 75;
@Injectable() @Injectable()
export class RoomsEffects { export class RoomsEffects {
private actions$ = inject(Actions); private actions$ = inject(Actions);
@@ -608,7 +611,12 @@ export class RoomsEffects {
navigationRequestVersion navigationRequestVersion
}); });
this.router.navigate(['/room', room.id]); window.setTimeout(() => {
if (this.signalingConnection.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
void this.router.navigate(['/room', room.id]);
}
}, 0);
return of(RoomsActions.viewServerSuccess({ room })); return of(RoomsActions.viewServerSuccess({ room }));
}; };
@@ -634,7 +642,9 @@ export class RoomsEffects {
onViewServerSuccess$ = createEffect(() => onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.viewServerSuccess), ofType(RoomsActions.viewServerSuccess),
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()]) switchMap(({ room }) => timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
))
) )
); );

View File

@@ -42,6 +42,20 @@ function getDefaultTextChannelId(room: Room): string {
return resolveActiveTextChannelId(enrichRoom(room).channels, 'general'); return resolveActiveTextChannelId(enrichRoom(room).channels, 'general');
} }
function activateRoomView(state: RoomsState, room: Room, isConnecting: boolean, updateSavedRooms = true): RoomsState {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: updateSavedRooms ? upsertRoom(state.savedRooms, enriched) : state.savedRooms,
isConnecting,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: getDefaultTextChannelId(enriched)
};
}
/** Upsert a room into a saved-rooms list (add or replace by id) */ /** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] { function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(room); const normalizedRoom = enrichRoom(room);
@@ -220,27 +234,24 @@ export const roomsReducer = createReducer(
})), })),
// View server - just switch the viewed room, stay connected // View server - just switch the viewed room, stay connected
on(RoomsActions.viewServer, (state) => ({ on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
...state, if (skipBanCheck) {
isConnecting: true, return {
signalServerCompatibilityError: null, ...activateRoomView(state, room, true, false),
error: null error: null
})), };
}
on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = enrichRoom(room);
return { return {
...state, ...state,
currentRoom: enriched, isConnecting: true,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
signalServerCompatibilityError: null, signalServerCompatibilityError: null,
isConnected: true, error: null
activeChannelId: getDefaultTextChannelId(enriched)
}; };
}), }),
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false, false)),
// Update room settings // Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({ on(RoomsActions.updateRoomSettings, (state) => ({
...state, ...state,

View File

@@ -10,7 +10,7 @@
/> />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob: file:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob: file:; img-src 'self' data: blob: file: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
/> />
<link <link
rel="icon" rel="icon"