Compare commits

...
7 Commits
Author SHA1 Message Date
myxelium 232a9ea8ea test: Ensure tests work after latest changes
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
Queue Release Build / build-windows (push) Successful in 25m4s
2026-05-19 00:52:28 +02:00
myxelium 54e8b9a5e4 feat: Update how messages load and sync, allow plugins to import messages
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
myxelium 94428ed170 fix: Mobile style fixes and other small ui fixes 2026-05-18 23:20:32 +02:00
myxelium afb64520ed perf: server navigation 2026-05-18 19:38:08 +02:00
myxelium 0152ed9dd2 fix: memory leak hunting and reconnecting on data error 2026-05-18 19:37:30 +02:00
myxelium dea114aed0 feat: Response mobile layout support v1
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
Queue Release Build / build-windows (push) Successful in 29m57s
2026-05-18 03:03:55 +02:00
myxelium ecb1a4b3a0 refactor: Remove hardcoded values
Queue Release Build / prepare (push) Successful in 2m28s
Deploy Web Apps / deploy (push) Successful in 7m58s
Queue Release Build / build-linux (push) Successful in 46m59s
Queue Release Build / build-windows (push) Successful in 26m2s
Queue Release Build / finalize (push) Successful in 23s
2026-05-17 18:18:14 +02:00
105 changed files with 4355 additions and 904 deletions
@@ -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. |
@@ -39,7 +39,6 @@ test.describe('Plugin API multi-user runtime', () => {
await closeSettingsModal(scenario.bob.page); await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
}); });
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => { await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
@@ -150,7 +149,7 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click(); await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 }); await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
+4 -2
View File
@@ -33,9 +33,11 @@ test.describe('Plugin manager UI', () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click(); const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
await pluginCard.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await pluginCard.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' }); const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 }); await expect(installDialog).toBeVisible({ timeout: 10_000 });
+32 -30
View File
@@ -90,10 +90,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 }); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect await expect
@@ -176,10 +173,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(0); .toBeGreaterThan(0);
await charlie.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(charlie.page);
await expect(charlie.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect(charlie.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0); await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000); await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
@@ -345,12 +339,7 @@ test.describe('Direct private calls', () => {
}) })
.toBeGreaterThan(charliePlayCountBeforeGroupCall); .toBeGreaterThan(charliePlayCountBeforeGroupCall);
await scenario.bob.page await answerIncomingCall(scenario.bob.page);
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), { .poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000, timeout: 10_000,
@@ -358,12 +347,7 @@ test.describe('Direct private calls', () => {
}) })
.toBe(0); .toBe(0);
await scenario.charlie.page await answerIncomingCall(scenario.charlie.page);
.getByRole('button', { name: 'Open private call' })
.last()
.click();
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
await expect await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), { .poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000, timeout: 10_000,
@@ -378,10 +362,7 @@ test.describe('Direct private calls', () => {
await test.step('Alice starts a private call and Bob joins', async () => { await test.step('Alice starts a private call and Bob joins', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000); await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000); await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
@@ -413,12 +394,11 @@ test.describe('Direct private calls', () => {
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => { await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click(); await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(incomingCallDialog(scenario.bob.page)).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0); await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0); await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect await expect
@@ -431,9 +411,7 @@ test.describe('Direct private calls', () => {
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => { await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob'); await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click(); await answerIncomingCall(scenario.bob.page);
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click(); await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 }); await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
@@ -633,6 +611,30 @@ async function startCallFromSearch(page: Page, userId: string, displayName: stri
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 }); await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
} }
function incomingCallDialog(page: Page) {
return page.getByRole('dialog', { name: /is calling/ });
}
async function answerIncomingCall(page: Page): Promise<void> {
const dialog = incomingCallDialog(page);
if (await dialog.isVisible({ timeout: 5_000 }).catch(() => false)) {
await dialog.getByRole('button', { name: 'Answer' }).click();
} else {
await page.getByRole('button', { name: 'Open private call' }).last().click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
const joinButton = page.getByRole('button', { name: 'Join call' });
if (await joinButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
await joinButton.click();
}
}
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> { async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? ''); return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
} }
+25 -9
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) ?? []));
} }
+10 -1
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 } }
+50 -8
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> {
+32 -3
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 {
+20
View File
@@ -50,6 +50,7 @@
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"swiper": "^12.1.4",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -31843,6 +31844,25 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/swiper": {
"version": "12.1.4",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.4.tgz",
"integrity": "sha512-bihiwoKMOQwW8FfdUbo1DgkVH25E+4ZELIq0oopL1KTKBteLuaTMi/wwFjMxtlhTkk45k3XQ89D1Fvv0spSqBA==",
"funding": [
{
"type": "custom",
"url": "https://sponsors.nolimits4web.com"
},
{
"type": "github",
"url": "https://github.com/sponsors/nolimits4web"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/swrv": { "node_modules/swrv": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz",
+1
View File
@@ -101,6 +101,7 @@
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"swiper": "^12.1.4",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
Binary file not shown.
+21 -6
View File
@@ -3,14 +3,16 @@
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground" class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
> >
<div <div
class="grid h-full min-h-0 min-w-0 overflow-hidden" class="h-full min-h-0 min-w-0 overflow-hidden"
[ngStyle]="appShellLayoutStyles()" [class.grid]="!isMobile()"
[class.flex]="isMobile()"
[ngStyle]="isMobile() ? null : appShellLayoutStyles()"
> >
<aside <aside
appThemeNode="serversRail" appThemeNode="serversRail"
class="min-h-0 overflow-hidden bg-transparent" class="min-h-0 overflow-hidden bg-transparent"
[class.hidden]="isThemeStudioFullscreen()" [class.hidden]="isThemeStudioFullscreen() || isMobile()"
[ngStyle]="serversRailLayoutStyles()" [ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
> >
<app-servers-rail class="block h-full" /> <app-servers-rail class="block h-full" />
</aside> </aside>
@@ -18,9 +20,12 @@
<main <main
appThemeNode="appWorkspace" appThemeNode="appWorkspace"
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background" class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
[ngStyle]="appWorkspaceShellStyles()" [class.flex-1]="isMobile()"
[ngStyle]="isMobile() ? null : appWorkspaceShellStyles()"
> >
<app-title-bar class="block shrink-0" /> @if (!isMobile()) {
<app-title-bar class="block shrink-0" />
}
<div class="relative min-h-0 flex-1 overflow-hidden"> <div class="relative min-h-0 flex-1 overflow-hidden">
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
@@ -88,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
+21 -6
View File
@@ -30,7 +30,7 @@ import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications'; import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service'; import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session'; import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform'; import { ExternalLinkService, ViewportService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service'; import { UserStatusService } from './core/services/user-status.service';
@@ -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
], ],
@@ -99,6 +101,8 @@ export class App implements OnInit, OnDestroy {
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
readonly voiceSession = inject(VoiceSessionFacade); readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService); readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService); readonly gameActivity = inject(GameActivityService);
@@ -263,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());
+1
View File
@@ -1,2 +1,3 @@
export * from './platform.service'; export * from './platform.service';
export * from './external-link.service'; export * from './external-link.service';
export * from './viewport.service';
@@ -0,0 +1,69 @@
import {
DestroyRef,
Injectable,
NgZone,
computed,
inject,
signal
} from '@angular/core';
/**
* Tracks viewport-level UX traits used to switch between desktop and mobile layouts.
*
* `isMobile` follows the Tailwind `md` breakpoint (max-width: 767.98px). It is the
* single source of truth for whether the UI should render in mobile mode - components
* and templates should use this signal rather than ad-hoc `window.innerWidth` checks.
*
* `isTouch` is a best-effort hint indicating coarse pointer / touch capability. It is
* stable for the lifetime of the page and does not flip when devices are connected.
*/
@Injectable({ providedIn: 'root' })
export class ViewportService {
/** Pixel breakpoint that separates mobile from tablet/desktop layouts. Matches Tailwind `md`. */
static readonly MOBILE_MAX_WIDTH = 767.98;
/** True when the viewport is in mobile mode (width <= MOBILE_MAX_WIDTH). */
readonly isMobile = computed(() => this.isMobileSignal());
/** True when the primary pointer is coarse (touch screen). */
readonly isTouch = computed(() => this.isTouchSignal());
/** Convenience: true when running on a non-mobile viewport. */
readonly isDesktop = computed(() => !this.isMobileSignal());
private readonly zone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly mobileQuery: MediaQueryList | null;
private readonly touchQuery: MediaQueryList | null;
private readonly isMobileSignal = signal(false);
private readonly isTouchSignal = signal(false);
constructor() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
this.mobileQuery = null;
this.touchQuery = null;
return;
}
this.mobileQuery = window.matchMedia(`(max-width: ${ViewportService.MOBILE_MAX_WIDTH}px)`);
this.touchQuery = window.matchMedia('(pointer: coarse)');
this.isMobileSignal.set(this.mobileQuery.matches);
this.isTouchSignal.set(this.touchQuery.matches);
const onMobileChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isMobileSignal.set(event.matches));
};
const onTouchChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isTouchSignal.set(event.matches));
};
this.mobileQuery.addEventListener('change', onMobileChange);
this.touchQuery.addEventListener('change', onTouchChange);
this.destroyRef.onDestroy(() => {
this.mobileQuery?.removeEventListener('change', onMobileChange);
this.touchQuery?.removeEventListener('change', onTouchChange);
});
}
}
@@ -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;
@@ -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 {
@@ -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
@@ -40,31 +43,43 @@
</div> </div>
@if (showKlipyGifPicker()) { @if (showKlipyGifPicker()) {
<div @if (isMobile()) {
class="fixed inset-0 z-[89]" <app-bottom-sheet (dismissed)="closeKlipyGifPicker()">
(click)="closeKlipyGifPicker()" <div appThemeNode="chatGifPickerSurface">
(keydown.enter)="closeKlipyGifPicker()" <app-klipy-gif-picker
(keydown.space)="closeKlipyGifPicker()" [signalSource]="currentRoom()"
tabindex="0" (gifSelected)="handleKlipyGifSelected($event)"
role="button" (closed)="closeKlipyGifPicker()"
aria-label="Close GIF picker" />
style="-webkit-app-region: no-drag" </div>
></div> </app-bottom-sheet>
} @else {
<div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface" class="fixed inset-0 z-[89]"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" (click)="closeKlipyGifPicker()"
[style.bottom.px]="composerBottomPadding() + 8" (keydown.enter)="closeKlipyGifPicker()"
[style.right.px]="klipyGifPickerAnchorRight()" (keydown.space)="closeKlipyGifPicker()"
> tabindex="0"
<app-klipy-gif-picker role="button"
[signalSource]="currentRoom()" aria-label="Close GIF picker"
(gifSelected)="handleKlipyGifSelected($event)" style="-webkit-app-region: no-drag"
(closed)="closeKlipyGifPicker()" ></div>
/>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div> </div>
</div> }
} }
<app-chat-message-overlays <app-chat-message-overlays
@@ -8,15 +8,21 @@ 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 { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; 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';
@@ -45,6 +51,7 @@ import {
KlipyGifPickerComponent, KlipyGifPickerComponent,
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
@@ -59,6 +66,9 @@ export class ChatMessagesComponent {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly allMessages = this.store.selectSignal(selectAllMessages); readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -66,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);
@@ -77,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);
@@ -207,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();
@@ -6,6 +6,10 @@
[attr.data-message-id]="msg.id" [attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30" class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted" [class.opacity-50]="msg.isDeleted"
(touchstart)="onMessageTouchStart($event)"
(touchend)="onMessageTouchEnd()"
(touchmove)="onMessageTouchEnd()"
(touchcancel)="onMessageTouchEnd()"
> >
<div <div
appThemeNode="chatMessageAvatar" appThemeNode="chatMessageAvatar"
@@ -469,7 +473,7 @@
} }
</div> </div>
@if (!msg.isDeleted) { @if (!msg.isDeleted && !isMobile()) {
<div <div
appThemeNode="chatMessageActions" appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100" class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
@@ -534,4 +538,83 @@
} }
</div> </div>
} }
<ng-template #mobileSheetTpl>
<app-bottom-sheet
title="Message"
ariaLabel="Message actions"
(dismissed)="closeMobileActions()"
>
<div class="flex flex-col py-1">
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (emoji of commonEmojis; track emoji) {
<button
type="button"
class="rounded p-1 text-xl transition-colors hover:bg-secondary"
(click)="onMobileReact(emoji)"
>
{{ emoji }}
</button>
}
</div>
</div>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileReply()"
>
<ng-icon
name="lucideReply"
class="h-5 w-5 text-muted-foreground"
/>
<span>Reply</span>
</button>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileCopy()"
>
<ng-icon
name="lucideCopy"
class="h-5 w-5 text-muted-foreground"
/>
<span>Copy message content</span>
</button>
@if (isOwnMessage()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileEdit()"
>
<ng-icon
name="lucideEdit"
class="h-5 w-5 text-muted-foreground"
/>
<span>Edit</span>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-destructive transition-colors hover:bg-destructive/10"
(click)="onMobileDelete()"
>
<ng-icon
name="lucideTrash2"
class="h-5 w-5"
/>
<span>Delete</span>
</button>
}
</div>
</app-bottom-sheet>
</ng-template>
</div> </div>
@@ -2,21 +2,28 @@
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,
effect, effect,
inject, inject,
input, input,
OnDestroy,
output, output,
signal, signal,
ViewChild TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
@@ -34,7 +41,7 @@ import {
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES, MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment'; } from '../../../../../attachment';
import { PlatformService } from '../../../../../../core/platform'; import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { import {
ExperimentalMediaSettingsService ExperimentalMediaSettingsService
@@ -52,6 +59,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import { import {
BottomSheetComponent,
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ProfileCardService, ProfileCardService,
@@ -125,11 +133,13 @@ interface MissingPluginEmbedFallback {
UserAvatarComponent, UserAvatarComponent,
PluginRenderHostComponent, PluginRenderHostComponent,
ExperimentalVlcPlayerComponent, ExperimentalVlcPlayerComponent,
ThemeNodeDirective ThemeNodeDirective,
BottomSheetComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
@@ -144,12 +154,14 @@ 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;'
} }
}) })
export class ChatMessageItemComponent { export class ChatMessageItemComponent implements OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
@@ -160,6 +172,13 @@ export class ChatMessageItemComponent {
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
private readonly profileCard = inject(ProfileCardService); private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null); private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>(); private readonly mediaSupportCache = new Map<string, boolean>();
@@ -360,6 +379,116 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message()); this.deleteRequested.emit(this.message());
} }
onMessageTouchStart(event: TouchEvent): void {
if (!this.isMobile() || this.message().isDeleted) {
return;
}
if (event.touches.length !== 1) {
this.clearLongPressTimer();
return;
}
if (this.isEditableTarget(event.target)) {
this.clearLongPressTimer();
return;
}
this.clearLongPressTimer();
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openMobileSheet();
}, 500);
}
onMessageTouchEnd(): void {
this.clearLongPressTimer();
}
private clearLongPressTimer(): void {
if (this.longPressTimer !== null) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null;
}
closeMobileActions(): void {
this.detachMobileSheet();
}
private openMobileSheet(): void {
if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) {
this.mobileSheetOpen.set(true);
return;
}
const overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global(),
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: false,
panelClass: 'metoyou-chat-actions-sheet-pane'
});
const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef);
overlayRef.attach(portal);
this.mobileSheetOverlayRef = overlayRef;
this.mobileSheetOpen.set(true);
}
private detachMobileSheet(): void {
this.mobileSheetOpen.set(false);
if (this.mobileSheetOverlayRef) {
this.mobileSheetOverlayRef.dispose();
this.mobileSheetOverlayRef = null;
}
}
ngOnDestroy(): void {
this.clearLongPressTimer();
this.detachMobileSheet();
}
onMobileReact(emoji: string): void {
this.addReaction(emoji);
this.closeMobileActions();
}
onMobileReply(): void {
this.requestReply();
this.closeMobileActions();
}
onMobileEdit(): void {
this.startEdit();
this.closeMobileActions();
}
onMobileDelete(): void {
this.requestDelete();
this.closeMobileActions();
}
async onMobileCopy(): Promise<void> {
const text = this.message().content;
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API unavailable; silently ignore.
}
this.closeMobileActions();
}
removeEmbed(url: string): void { removeEmbed(url: string): void {
this.embedRemoved.emit({ this.embedRemoved.emit({
messageId: this.message().id, messageId: this.message().id,
@@ -5,9 +5,10 @@ import {
input input
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { environment } from '../../../../../../../../environments/environment';
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules'; import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app'; const YOUTUBE_EMBED_FALLBACK_ORIGIN = environment.publicOrigin;
function resolveYoutubeClientOrigin(): string { function resolveYoutubeClientOrigin(): string {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -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 {
@@ -4,27 +4,29 @@
aria-label="KLIPY GIF picker" aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
> >
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4"> @if (!isMobile()) {
<div> <div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div> <div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3> <div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<p class="mt-1 text-sm text-muted-foreground"> <h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }} <p class="mt-1 text-sm text-muted-foreground">
</p> {{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</div> </p>
</div>
<button <button
type="button" type="button"
(click)="close()" (click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker" aria-label="Close GIF picker"
> >
<ng-icon <ng-icon
name="lucideX" name="lucideX"
class="h-4 w-4" class="h-4 w-4"
/> />
</button> </button>
</div> </div>
}
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4"> <div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block"> <label class="relative block">
@@ -37,7 +39,7 @@
type="text" type="text"
[ngModel]="searchQuery" [ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)" (ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY" [placeholder]="isMobile() ? 'Search KLIPY and add a gif to the chat' : 'Search KLIPY'"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary" class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</label> </label>
@@ -80,12 +82,14 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="columns-[12rem] gap-4"> <div [class]="isMobile() ? 'grid grid-cols-2 gap-2' : 'columns-[12rem] gap-4'">
@for (gif of results(); track gif.id) { @for (gif of results(); track gif.id) {
<button <button
type="button" type="button"
(click)="selectGif(gif)" (click)="selectGif(gif)"
class="group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30" [class]="isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'"
> >
<div <div
class="relative flex items-center justify-center overflow-hidden bg-secondary/30" class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
@@ -104,30 +108,55 @@
KLIPY KLIPY
</span> </span>
</div> </div>
<div class="px-3 py-2"> @if (!isMobile()) {
<p class="truncate text-xs font-medium text-foreground"> <div class="px-3 py-2">
{{ gif.title || 'KLIPY GIF' }} <p class="truncate text-xs font-medium text-foreground">
</p> {{ gif.title || 'KLIPY GIF' }}
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p> </p>
</div> <p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
}
</button> </button>
} }
</div> </div>
@if (isMobile() && hasNext()) {
<div class="mt-3 flex justify-center">
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/80 bg-background/60 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
[attr.aria-label]="loading() ? 'Loading more GIFs' : 'Load more GIFs'"
>
@if (loading()) {
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
} @else {
<ng-icon
name="lucideChevronDown"
class="h-5 w-5"
/>
}
</button>
</div>
}
} }
</div> </div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4"> @if (!isMobile()) {
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p> <div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) { @if (hasNext()) {
<button <button
type="button" type="button"
(click)="loadMore()" (click)="loadMore()"
[disabled]="loading()" [disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
> >
{{ loading() ? 'Loading...' : 'Load more' }} {{ loading() ? 'Loading...' : 'Load more' }}
</button> </button>
} }
</div> </div>
}
</div> </div>
@@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -24,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory'; import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140; const KLIPY_CARD_MIN_WIDTH = 140;
const KLIPY_CARD_MAX_WIDTH = 248; const KLIPY_CARD_MAX_WIDTH = 248;
@@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1; private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null; private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0; private requestId = 0;
@@ -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);
@@ -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"
@@ -97,29 +119,40 @@
</div> </div>
@if (showGifPicker()) { @if (showGifPicker()) {
<div @if (isMobile()) {
class="fixed inset-0 z-[89]" <app-bottom-sheet (dismissed)="closeGifPicker()">
tabindex="0" <div appThemeNode="chatGifPickerSurface">
role="button" <app-klipy-gif-picker
aria-label="Close GIF picker" (gifSelected)="handleGifSelected($event)"
(click)="closeGifPicker()" (closed)="closeGifPicker()"
(keydown.enter)="closeGifPicker()" />
(keydown.space)="closeGifPicker()" </div>
></div> </app-bottom-sheet>
} @else {
<div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface" class="fixed inset-0 z-[89]"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" tabindex="0"
[style.bottom.px]="composerBottomPadding() + 8" role="button"
[style.right.px]="gifPickerAnchorRight()" aria-label="Close GIF picker"
> (click)="closeGifPicker()"
<app-klipy-gif-picker (keydown.enter)="closeGifPicker()"
(gifSelected)="handleGifSelected($event)" (keydown.space)="closeGifPicker()"
(closed)="closeGifPicker()" ></div>
/>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
</div>
</div> </div>
</div> }
} }
<app-chat-message-overlays <app-chat-message-overlays
@@ -15,7 +15,12 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop'; 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 { UserAvatarComponent } from '../../../../shared'; import { ViewportService } from '../../../../core/platform';
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';
@@ -61,6 +66,7 @@ interface DmStatusLabel {
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
BottomSheetComponent,
NgIcon, NgIcon,
ThemeNodeDirective, ThemeNodeDirective,
UserAvatarComponent UserAvatarComponent
@@ -80,8 +86,11 @@ 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 metadataRequestKeys = new Set<string>(); private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null; private openedConversationId: string | null = null;
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService); readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -305,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);
} }
@@ -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"
@@ -1,6 +1,6 @@
<main <main
appThemeNode="dmChatPanel" appThemeNode="dmChatPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background" class="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()" [ngStyle]="chatPanelStyles()"
> >
<app-dm-chat /> <app-dm-chat />
@@ -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"
@@ -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]);
} }
@@ -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>
@@ -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;
@@ -1,7 +1,69 @@
<div @if (isMobile()) {
class="grid h-full min-h-0 overflow-hidden bg-background" <!-- Mobile: Swiper-driven page stack (conversations -> chat) -->
[ngStyle]="layoutStyles()" <swiper-container
> #swiperEl
<app-dm-conversations-panel /> class="block h-full min-h-0 w-full bg-background"
<app-dm-chat-panel /> slides-per-view="1"
</div> space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<app-dm-conversations-panel
(conversationSelected)="setMobilePage('chat')"
class="block h-full w-full"
/>
</div>
</div>
</swiper-slide>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden">
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
<button
type="button"
(click)="setMobilePage('conversations')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to conversations"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<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 class="min-h-0 flex-1 overflow-hidden">
<app-dm-chat-panel class="block h-full w-full" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<!-- Desktop: theme-driven 2-pane grid layout -->
<div
class="grid h-full min-h-0 overflow-hidden bg-background"
[ngStyle]="layoutStyles()"
>
<app-dm-conversations-panel />
<app-dm-chat-panel />
</div>
}
@@ -1,46 +1,107 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
ElementRef,
NgZone,
OnDestroy,
computed, computed,
effect, effect,
inject, inject,
OnDestroy signal,
viewChild
} 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';
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 { lucideChevronLeft, lucidePhoneCall } from '@ng-icons/lucide';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
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';
/** Mobile-only page identifier within the DM workspace flow. */
export type DmWorkspaceMobilePage = 'conversations' | 'chat';
const PAGE_TO_INDEX: Record<DmWorkspaceMobilePage, number> = {
conversations: 0,
chat: 1
};
const INDEX_TO_PAGE: DmWorkspaceMobilePage[] = ['conversations', 'chat'];
interface SwiperElement extends HTMLElement {
swiper?: { activeIndex: number; slideTo: (index: number, speed?: number) => void };
}
@Component({ @Component({
selector: 'app-dm-workspace', selector: 'app-dm-workspace',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
NgIcon,
DmChatPanelComponent, DmChatPanelComponent,
DmConversationsPanelComponent DmConversationsPanelComponent,
ServersRailComponent
], ],
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './dm-workspace.component.html' templateUrl: './dm-workspace.component.html'
}) })
export class DmWorkspaceComponent implements OnDestroy { export class DmWorkspaceComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly theme = inject(ThemeService); private readonly theme = inject(ThemeService);
private readonly viewport = inject(ViewportService);
private readonly zone = inject(NgZone);
private readonly directCalls = inject(DirectCallService);
private lastSeenConversationId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
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')
}); });
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile;
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. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
constructor() { constructor() {
effect(() => { effect(() => {
const conversationId = this.routeConversationId(); const conversationId = this.routeConversationId();
const isMobile = this.isMobile();
if (conversationId) { if (conversationId) {
void this.directMessages.openConversation(conversationId); void this.directMessages.openConversation(conversationId);
// Only auto-advance to the chat page when the conversation actually changes.
// Without this, pressing Back to the conversations list immediately bounces
// us forward again because the conversation id is still the same.
if (isMobile && conversationId !== this.lastSeenConversationId) {
this.mobilePage.set('chat');
}
this.lastSeenConversationId = conversationId;
return;
}
this.lastSeenConversationId = null;
// On mobile, stay on the conversations list and let the user pick one explicitly.
if (isMobile) {
this.mobilePage.set('conversations');
return; return;
} }
@@ -50,9 +111,63 @@ export class DmWorkspaceComponent implements OnDestroy {
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true }); void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
} }
}); });
// Mirror `mobilePage` into the Swiper instance so route-driven page changes and the
// header back button actually slide the carousel.
effect(() => {
const el = this.swiperRef()?.nativeElement;
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
el.swiper.slideTo(targetIndex);
}
});
// Bridge Swiper's slidechange event back into `mobilePage`.
effect((onCleanup) => {
const el = this.swiperRef()?.nativeElement;
if (!el || el === this.swiperListenerAttached) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
const index = swiper?.activeIndex ?? 0;
const page = INDEX_TO_PAGE[index] ?? 'conversations';
this.zone.run(() => this.mobilePage.set(page));
};
el.addEventListener('swiperslidechange', handler);
this.swiperListenerAttached = el;
onCleanup(() => {
el.removeEventListener('swiperslidechange', handler);
if (this.swiperListenerAttached === el) {
this.swiperListenerAttached = null;
}
});
});
}
/** Set the active mobile page. No-op on desktop. */
setMobilePage(page: DmWorkspaceMobilePage): void {
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());
} }
} }
@@ -1,5 +1,6 @@
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
export interface ExperimentalVlcPlayerOptions { export interface ExperimentalVlcPlayerOptions {
container: HTMLElement; container: HTMLElement;
@@ -23,7 +24,7 @@ declare global {
} }
} }
const VLC_RUNTIME_SCRIPT_URL = '/vlcjs/metoyou-vlc-player.js'; const VLC_RUNTIME_SCRIPT_URL = environment.experimentalMedia.vlcRuntimeScriptUrl;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ExperimentalVlcRuntimeService { export class ExperimentalVlcRuntimeService {
@@ -60,6 +61,7 @@ export class ExperimentalVlcRuntimeService {
script.src = VLC_RUNTIME_SCRIPT_URL; script.src = VLC_RUNTIME_SCRIPT_URL;
script.async = true; script.async = true;
script.onload = () => { script.onload = () => {
const runtime = this.document.defaultView?.MetoYouVlcJs; const runtime = this.document.defaultView?.MetoYouVlcJs;
@@ -70,6 +72,7 @@ export class ExperimentalVlcRuntimeService {
resolve(runtime); resolve(runtime);
}; };
script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`)); script.onerror = () => reject(new Error(`The experimental VLC.js runtime was not found at ${VLC_RUNTIME_SCRIPT_URL}.`));
this.document.head.appendChild(script); this.document.head.appendChild(script);
+1 -1
View File
@@ -10,7 +10,7 @@ The standalone plugin store is available from the title bar Plugins button, the
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs. The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client. The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. New users and legacy source lists are seeded with the official Toju plugin repository at `https://raw.githubusercontent.com/Myxelium/official-toju-plugin-repository/refs/heads/master/plugin-source.json`, while source removal remains persisted after migration. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. When a different user joins that server, required plugins block the join until the user accepts the download; optional and recommended plugins are offered as selectable downloads and can be skipped. Once a server has local server-scoped plugins installed, the title bar shows a compact Server plugins button for that server. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically. Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL; the renderer CSP allows HTTP(S), `file://` via local cache, and blob-backed plugin entrypoints. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
@@ -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);
@@ -1,4 +1,5 @@
import { Injector } from '@angular/core'; import { Injector } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { TojuPluginManifest } from '../../../../shared-kernel'; import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PluginStoreService } from './plugin-store.service'; import { PluginStoreService } from './plugin-store.service';
@@ -6,8 +7,11 @@ import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service'; import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
import { PluginCapabilityService } from './plugin-capability.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models'; import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
const OFFICIAL_PLUGIN_SOURCE_URL = environment.pluginStore.defaultSourceUrls[0];
describe('PluginStoreService', () => { describe('PluginStoreService', () => {
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
let registerLocalManifest: ReturnType<typeof vi.fn>; let registerLocalManifest: ReturnType<typeof vi.fn>;
@@ -36,42 +40,79 @@ describe('PluginStoreService', () => {
}); });
it('loads plugin entries from source manifests and resolves relative links', async () => { it('loads plugin entries from source manifests and resolves relative links', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ mockFetchResponses(fetchMock, {
plugins: [ 'https://plugins.example.test/index.json': jsonResponse({
{ plugins: [
author: 'Ada Example', {
description: 'Adds better channel tools.', author: 'Ada Example',
github: 'https://github.com/example/better-channels', description: 'Adds better channel tools.',
id: 'example.better-channels', github: 'https://github.com/example/better-channels',
image: './images/better.png', id: 'example.better-channels',
install: './better/toju-plugin.json', image: './images/better.png',
readme: './better/README.md', install: './better/toju-plugin.json',
title: 'Better Channels', readme: './better/README.md',
version: '1.2.0' title: 'Better Channels',
} version: '1.2.0'
], }
title: 'Example Plugins' ],
})); title: 'Example Plugins'
})
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
await service.addSourceUrl('https://plugins.example.test/index.json#latest'); await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
expect(service.sources()[0]?.title).toBe('Example Plugins');
expect(service.availablePlugins()).toEqual([ expect(service.sources().some((source) => source.title === 'Example Plugins')).toBe(true);
expect(service.availablePlugins()).toContainEqual(expect.objectContaining({
author: 'Ada Example',
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
title: 'Better Channels',
version: '1.2.0'
}));
});
it('seeds the official plugin repository for new users', () => {
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
expect(fetchMock).toHaveBeenCalledWith(
OFFICIAL_PLUGIN_SOURCE_URL,
expect.objectContaining({ expect.objectContaining({
author: 'Ada Example', headers: { Accept: 'application/json' }
githubUrl: 'https://github.com/example/better-channels',
id: 'example.better-channels',
imageUrl: 'https://plugins.example.test/images/better.png',
installUrl: 'https://plugins.example.test/better/toju-plugin.json',
readmeUrl: 'https://plugins.example.test/better/README.md',
sourceTitle: 'Example Plugins',
title: 'Better Channels',
version: '1.2.0'
}) })
]); );
});
it('adds the official plugin repository when loading legacy source lists', () => {
storage.setItem('metoyou_plugin_store', JSON.stringify({
installedPlugins: [],
schemaVersion: 1,
sourceUrls: ['https://plugins.example.test/index.json']
}));
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
});
it('keeps user-removed default sources removed after schema migration', () => {
storage.setItem('metoyou_plugin_store', JSON.stringify({
installedPlugins: [],
schemaVersion: 2,
sourceUrls: ['https://plugins.example.test/index.json']
}));
const service = createService(registerLocalManifest, unregister);
expect(service.sourceUrls()).toEqual(['https://plugins.example.test/index.json']);
}); });
it('accepts local source manifest paths and resolves relative file links', async () => { it('accepts local source manifest paths and resolves relative file links', async () => {
@@ -94,9 +135,10 @@ describe('PluginStoreService', () => {
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json'); await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json'); expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']); expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
expect(service.availablePlugins()).toEqual([ expect(service.availablePlugins()).toEqual([
expect.objectContaining({ expect.objectContaining({
id: 'example.local-plugin', id: 'example.local-plugin',
@@ -112,7 +154,9 @@ describe('PluginStoreService', () => {
const manifest = createManifest({ version: '1.0.0' }); const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' }); const plugin = createStoreEntry({ version: '1.0.0' });
fetchMock.mockResolvedValueOnce(jsonResponse(manifest)); mockFetchResponses(fetchMock, {
[plugin.installUrl ?? '']: jsonResponse(manifest)
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
@@ -141,9 +185,10 @@ describe('PluginStoreService', () => {
writeFile: vi.fn(async () => true) writeFile: vi.fn(async () => true)
}; };
fetchMock mockFetchResponses(fetchMock, {
.mockResolvedValueOnce(jsonResponse(manifest)) [plugin.bundleUrl ?? '']: textResponse('export function activate() {}'),
.mockResolvedValueOnce(textResponse('export function activate() {}')); [plugin.installUrl ?? '']: jsonResponse(manifest)
});
const service = createService(registerLocalManifest, unregister, electronApi); const service = createService(registerLocalManifest, unregister, electronApi);
@@ -171,7 +216,9 @@ describe('PluginStoreService', () => {
it('loads plugin readmes as markdown text', async () => { it('loads plugin readmes as markdown text', async () => {
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' }); const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
fetchMock.mockResolvedValueOnce(textResponse('# Better Channels')); mockFetchResponses(fetchMock, {
[plugin.readmeUrl ?? '']: textResponse('# Better Channels')
});
const service = createService(registerLocalManifest, unregister); const service = createService(registerLocalManifest, unregister);
const readme = await service.loadReadme(plugin); const readme = await service.loadReadme(plugin);
@@ -185,6 +232,21 @@ describe('PluginStoreService', () => {
}); });
}); });
function mockFetchResponses(fetchMock: ReturnType<typeof vi.fn>, responses: Record<string, Response>): void {
fetchMock.mockImplementation(async (url: RequestInfo | URL) => {
const requestUrl = typeof url === 'string'
? url
: url instanceof URL
? url.toString()
: url.url;
return responses[requestUrl]
?? (requestUrl === OFFICIAL_PLUGIN_SOURCE_URL
? jsonResponse({ plugins: [], title: 'Official Toju Plugins' })
: textResponse(''));
});
}
function createService( function createService(
registerLocalManifest: ReturnType<typeof vi.fn>, registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>, unregister: ReturnType<typeof vi.fn>,
@@ -220,6 +282,12 @@ function createService(
writeJson: vi.fn(async () => undefined) writeJson: vi.fn(async () => undefined)
} }
}, },
{
provide: PluginCapabilityService,
useValue: {
grantAll: vi.fn()
}
},
{ {
provide: PluginRegistryService, provide: PluginRegistryService,
useValue: { unregister } useValue: { unregister }
@@ -10,6 +10,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../../../environments/environment';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage'; import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
@@ -44,14 +45,11 @@ import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service'; import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service'; import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1; const STORE_SCHEMA_VERSION = 2;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store'; const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs'; const STORAGE_KEY_SERVER_PLUGIN_INSTALLS = 'metoyou_server_plugin_installs';
const PLUGIN_CACHE_DIR = 'plugin-bundles'; const PLUGIN_CACHE_DIR = 'plugin-bundles';
const DEFAULT_STORE_STATE: PersistedPluginStoreState = { const DEFAULT_PLUGIN_SOURCE_URLS = [...environment.pluginStore.defaultSourceUrls];
installedPlugins: [],
sourceUrls: []
};
export interface PluginStoreInstallOptions { export interface PluginStoreInstallOptions {
activate?: boolean; activate?: boolean;
@@ -953,12 +951,12 @@ export class PluginStoreService {
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE)); const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE));
if (!raw) { if (!raw) {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
return normalizePersistedState(JSON.parse(raw) as unknown); return normalizePersistedState(JSON.parse(raw) as unknown);
} catch { } catch {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
} }
@@ -1109,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),
@@ -1132,22 +1130,63 @@ function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInst
function normalizePersistedState(value: unknown): PersistedPluginStoreState { function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) { if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE }; return createDefaultStoreState();
} }
const schemaVersion = typeof value['schemaVersion'] === 'number' ? value['schemaVersion'] : 0;
const sourceUrls = Array.isArray(value['sourceUrls'])
? normalizePluginSourceUrls(value['sourceUrls'])
: [];
return { return {
installedPlugins: Array.isArray(value['installedPlugins']) installedPlugins: Array.isArray(value['installedPlugins'])
? value['installedPlugins'].filter(isInstalledStorePlugin) ? value['installedPlugins'].filter(isInstalledStorePlugin)
: [], : [],
sourceUrls: Array.isArray(value['sourceUrls']) schemaVersion: STORE_SCHEMA_VERSION,
? value['sourceUrls'] sourceUrls: schemaVersion < STORE_SCHEMA_VERSION
.filter((entry): entry is string => typeof entry === 'string') ? mergePluginSourceUrls(DEFAULT_PLUGIN_SOURCE_URLS, sourceUrls)
.map((entry) => normalizeOptionalSourceUrl(entry)) : sourceUrls
.filter((entry): entry is string => !!entry)
: []
}; };
} }
function createDefaultStoreState(): PersistedPluginStoreState {
return {
installedPlugins: [],
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: [...DEFAULT_PLUGIN_SOURCE_URLS]
};
}
function normalizePluginSourceUrls(sourceUrls: unknown[]): string[] {
const normalizedSourceUrls: string[] = [];
for (const entry of sourceUrls) {
if (typeof entry !== 'string') {
continue;
}
const sourceUrl = normalizeOptionalSourceUrl(entry);
if (sourceUrl && !normalizedSourceUrls.includes(sourceUrl)) {
normalizedSourceUrls.push(sourceUrl);
}
}
return normalizedSourceUrls;
}
function mergePluginSourceUrls(defaultSourceUrls: string[], sourceUrls: string[]): string[] {
const mergedSourceUrls: string[] = [];
for (const sourceUrl of defaultSourceUrls.concat(sourceUrls)) {
if (!mergedSourceUrls.includes(sourceUrl)) {
mergedSourceUrls.push(sourceUrl);
}
}
return mergedSourceUrls;
}
function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } { function normalizePersistedServerPluginInstallState(value: unknown): { servers: Record<string, InstalledStorePlugin[]> } {
if (!isRecord(value) || !isRecord(value['servers'])) { if (!isRecord(value) || !isRecord(value['servers'])) {
return { servers: {} }; return { servers: {} };
@@ -1261,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;
@@ -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;
}; };
@@ -48,6 +48,7 @@ export interface PluginStoreReadme {
export interface PersistedPluginStoreState { export interface PersistedPluginStoreState {
installedPlugins: InstalledStorePlugin[]; installedPlugins: InstalledStorePlugin[];
schemaVersion?: number;
sourceUrls: string[]; sourceUrls: string[];
} }
@@ -15,6 +15,7 @@ import {
fromEvent fromEvent
} from 'rxjs'; } from 'rxjs';
import { PluginActionMenuComponent } from './plugin-action-menu.component'; import { PluginActionMenuComponent } from './plugin-action-menu.component';
import { ViewportService } from '../../../../core/platform';
const GAP = 10; const GAP = 10;
const VIEWPORT_MARGIN = 8; const VIEWPORT_MARGIN = 8;
@@ -28,6 +29,7 @@ const POSITIONS: ConnectedPosition[] = [
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginActionMenuService { export class PluginActionMenuService {
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly viewport = inject(ViewportService);
private currentOrigin: HTMLElement | null = null; private currentOrigin: HTMLElement | null = null;
private overlayRef: OverlayRef | null = null; private overlayRef: OverlayRef | null = null;
private overlaySubscriptions: Subscription | null = null; private overlaySubscriptions: Subscription | null = null;
@@ -47,20 +49,38 @@ export class PluginActionMenuService {
} }
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const isMobile = this.viewport.isMobile();
this.currentOrigin = rawEl; this.currentOrigin = rawEl;
const positionStrategy = this.overlay if (isMobile) {
.position() const positionStrategy = this.overlay
.flexibleConnectedTo(elementRef) .position()
.withPositions(POSITIONS) .global()
.withViewportMargin(VIEWPORT_MARGIN) .left('0')
.withPush(true); .right('0')
.bottom('0');
this.overlayRef = this.overlay.create({ this.overlayRef = this.overlay.create({
positionStrategy, positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop() scrollStrategy: this.overlay.scrollStrategies.block(),
}); hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'metoyou-bottom-sheet-panel'
});
} else {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(POSITIONS)
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
}
this.syncThemeVars(); this.syncThemeVars();
@@ -68,6 +88,14 @@ export class PluginActionMenuService {
const subscriptions = new Subscription(); const subscriptions = new Subscription();
subscriptions.add(componentRef.instance.closed.subscribe(() => this.close())); subscriptions.add(componentRef.instance.closed.subscribe(() => this.close()));
if (isMobile) {
subscriptions.add(this.overlayRef.backdropClick().subscribe(() => this.close()));
this.overlaySubscriptions = subscriptions;
return;
}
subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown') subscriptions.add(fromEvent<PointerEvent>(document, 'pointerdown')
.pipe( .pipe(
filter((event) => { filter((event) => {
@@ -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"
@@ -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 {
@@ -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 {
@@ -1,6 +1,56 @@
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<div class="border-b border-border px-3 py-3"> <div class="border-b border-border px-3 py-3">
<div class="flex flex-col gap-2 md:flex-row md:items-center"> <!--
Mobile-only header row:
[Back] ----- Search ----- [Settings]
Hidden on >=md where the original inline header (search bar + buttons) is used.
-->
<div class="mb-2 flex items-center gap-2 md:hidden">
<button
type="button"
aria-label="Back to server view"
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"
[class.invisible]="!canGoBack()"
[disabled]="!canGoBack()"
(click)="goBack()"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</button>
<h1 class="min-w-0 flex-1 truncate text-center text-base font-semibold text-foreground">Search</h1>
@if (!currentUser()) {
<button
type="button"
aria-label="Log in"
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="lucideLogIn"
class="h-5 w-5"
/>
<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 class="flex flex-row items-center gap-2">
<div class="relative min-w-0 flex-1"> <div class="relative min-w-0 flex-1">
<ng-icon <ng-icon
name="lucideSearch" name="lucideSearch"
@@ -16,6 +66,7 @@
/> />
</div> </div>
<!-- Create button is shown inline next to the search input on all sizes; Settings is desktop-only here (mobile uses the top header row above). -->
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
@@ -27,12 +78,12 @@
name="lucidePlus" name="lucidePlus"
class="h-4 w-4" class="h-4 w-4"
/> />
Create <span>Create</span>
</button> </button>
<button <button
type="button" type="button"
class="grid h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80" class="hidden h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80 md:grid"
title="Settings" title="Settings"
(click)="openSettings()" (click)="openSettings()"
> >
@@ -60,13 +111,51 @@
} }
</div> </div>
<!-- Mobile tab strip: toggle between People and Servers panes (hidden on >=md) -->
<div
role="tablist"
aria-label="Search results"
class="flex border-b border-border md:hidden"
>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'people'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'people'"
[class.text-foreground]="mobileTab() === 'people'"
[class.border-transparent]="mobileTab() !== 'people'"
[class.text-muted-foreground]="mobileTab() !== 'people'"
(click)="mobileTab.set('people')"
>
People
</button>
<button
type="button"
role="tab"
[attr.aria-selected]="mobileTab() === 'servers'"
class="flex-1 px-3 py-2.5 text-sm font-medium transition-colors border-b-2"
[class.border-primary]="mobileTab() === 'servers'"
[class.text-foreground]="mobileTab() === 'servers'"
[class.border-transparent]="mobileTab() !== 'servers'"
[class.text-muted-foreground]="mobileTab() !== 'servers'"
(click)="mobileTab.set('servers')"
>
Servers ({{ searchResults().length }})
</button>
</div>
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]"> <div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
<app-user-search-list <app-user-search-list
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r" class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
[class.hidden]="isMobile() && mobileTab() !== 'people'"
[searchQuery]="searchQuery" [searchQuery]="searchQuery"
/> />
<section class="min-h-0 overflow-y-auto"> <section
class="min-h-0 overflow-y-auto"
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
>
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur"> <div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<div> <div>
<h3 class="text-sm font-semibold text-foreground">Servers</h3> <h3 class="text-sm font-semibold text-foreground">Servers</h3>
@@ -215,7 +304,7 @@
} @else { } @else {
<button <button
type="button" type="button"
class="pointer-events-none scale-95 rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground opacity-0 transition-[opacity,transform] duration-75 ease-out hover:scale-100 hover:opacity-100 group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100" class="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-[opacity,transform] duration-75 ease-out md:pointer-events-none md:scale-95 md:opacity-0 md:hover:scale-100 md:hover:opacity-100 md:group-hover:pointer-events-auto md:group-hover:scale-100 md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:scale-100 md:group-focus-within:opacity-100"
[attr.aria-label]="'Join ' + server.name" [attr.aria-label]="'Join ' + server.name"
(click)="joinServer(server)" (click)="joinServer(server)"
> >
@@ -18,6 +18,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideArrowLeft,
lucideExternalLink, lucideExternalLink,
lucideFileText, lucideFileText,
lucideSearch, lucideSearch,
@@ -26,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';
@@ -34,14 +36,15 @@ import {
selectSearchResults, selectSearchResults,
selectIsSearching, selectIsSearching,
selectRoomsError, selectRoomsError,
selectSavedRooms selectSavedRooms,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors'; } from '../../../../store/rooms/rooms.selectors';
import { import {
Room, Room,
User, User,
type PluginRequirementSummary type PluginRequirementSummary
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { ExternalLinkService } from '../../../../core/platform'; import { ExternalLinkService, ViewportService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model'; import { type ServerInfo } from '../../domain/models/server-directory.model';
@@ -83,6 +86,7 @@ interface JoinPluginConsentDialog {
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideArrowLeft,
lucideExternalLink, lucideExternalLink,
lucideFileText, lucideFileText,
lucideSearch, lucideSearch,
@@ -91,7 +95,8 @@ interface JoinPluginConsentDialog {
lucideGlobe, lucideGlobe,
lucidePlus, lucidePlus,
lucideSettings, lucideSettings,
lucideChevronDown lucideChevronDown,
lucideLogIn
}) })
], ],
templateUrl: './server-search.component.html' templateUrl: './server-search.component.html'
@@ -110,14 +115,22 @@ export class ServerSearchComponent implements OnInit {
private webrtc = inject(RealtimeSessionFacade); private webrtc = inject(RealtimeSessionFacade);
private pluginRequirements = inject(PluginRequirementService); private pluginRequirements = inject(PluginRequirementService);
private pluginStore = inject(PluginStoreService); private pluginStore = inject(PluginStoreService);
private viewport = inject(ViewportService);
private searchSubject = new Subject<string>(); private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0; private banLookupRequestVersion = 0;
/** True on mobile breakpoints. Drives the tabbed mobile layout. */
readonly isMobile = this.viewport.isMobile;
/** Active mobile tab. Ignored on desktop where both panes are visible side-by-side. */
readonly mobileTab = signal<'people' | 'servers'>('servers');
searchQuery = ''; searchQuery = '';
searchResults = this.store.selectSignal(selectSearchResults); searchResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching); isSearching = this.store.selectSignal(selectIsSearching);
error = this.store.selectSignal(selectRoomsError); error = this.store.selectSignal(selectRoomsError);
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
activeEndpoints = this.serverDirectory.activeServers; activeEndpoints = this.serverDirectory.activeServers;
bannedServerLookup = signal<Record<string, boolean>>({}); bannedServerLookup = signal<Record<string, boolean>>({});
@@ -235,6 +248,29 @@ 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).
* Prefers the current room; falls back to the first saved room. No-op when the user has not
* joined any servers.
*/
goBack(): void {
const target = this.currentRoom() ?? this.savedRooms()[0] ?? null;
if (target) {
this.store.dispatch(RoomsActions.viewServer({ room: target }));
}
}
/** True when the back button has a destination (user is in or has joined at least one server). */
canGoBack(): boolean {
return !!this.currentRoom() || this.savedRooms().length > 0;
}
/** Join a previously saved room by converting it to a ServerInfo payload. */ /** Join a previously saved room by converting it to a ServerInfo payload. */
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
this.openJoinedRoom(room); this.openJoinedRoom(room);
@@ -63,17 +63,19 @@
/> />
</button> </button>
<button @if (!isMobile()) {
(click)="toggleScreenShare()" <button
type="button" (click)="toggleScreenShare()"
[class]="getCompactScreenShareClass()" type="button"
title="Toggle Screen Share" [class]="getCompactScreenShareClass()"
> title="Toggle Screen Share"
<ng-icon >
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" <ng-icon
class="w-4 h-4" [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
/> class="w-4 h-4"
</button> />
</button>
}
<app-debug-console <app-debug-console
launcherVariant="compact" launcherVariant="compact"
@@ -24,6 +24,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection'; import { VoicePlaybackService } from '../../../../domains/voice-connection';
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share'; import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
import { ViewportService } from '../../../../core/platform';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
@@ -59,6 +60,8 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
export class FloatingVoiceControlsComponent implements OnInit { export class FloatingVoiceControlsComponent implements OnInit {
private readonly webrtcService = inject(VoiceConnectionFacade); private readonly webrtcService = inject(VoiceConnectionFacade);
private readonly screenShareService = inject(ScreenShareFacade); private readonly screenShareService = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private readonly voiceSessionService = inject(VoiceSessionFacade); private readonly voiceSessionService = inject(VoiceSessionFacade);
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store); private readonly store = inject(Store);
@@ -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"
@@ -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>
@@ -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 {
@@ -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
@@ -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[] {
@@ -1,71 +1,203 @@
<div class="flex h-full flex-col bg-background"> <div class="flex h-full flex-col bg-background">
@if (currentRoom()) { @if (currentRoom()) {
<div @if (isMobile()) {
class="grid min-h-0 flex-1 overflow-hidden" <!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
[ngStyle]="roomLayoutStyles()" <swiper-container
> #swiperEl
<aside class="block min-h-0 w-full flex-1"
appThemeNode="chatRoomChannelsPanel" slides-per-view="1"
class="flex min-h-0 overflow-hidden border-r border-border bg-card" space-between="0"
[ngStyle]="channelsPanelLayoutStyles()" initial-slide="0"
threshold="10"
resistance-ratio="0"
> >
<app-rooms-side-panel <swiper-slide class="block h-full w-full">
panelMode="channels" <div class="flex h-full w-full min-h-0 overflow-hidden">
class="block h-full w-full" <app-servers-rail class="block h-full shrink-0" />
/> <div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
</aside> <app-rooms-side-panel
panelMode="channels"
<main (textChannelSelected)="setMobilePage('main')"
appThemeNode="chatRoomMainPanel" class="block h-full w-full"
class="relative min-h-0 min-w-0 overflow-hidden bg-background" />
[ngStyle]="mainPanelLayoutStyles()"
>
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div> </div>
} @else { </div>
<div </swiper-slide>
appThemeNode="chatRoomEmptyState"
class="flex h-full items-center justify-center px-6" <swiper-slide class="block h-full w-full">
> <div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-background">
<div class="max-w-md text-center text-muted-foreground"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
<div <button
data-theme-slot="icon" type="button"
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground" (click)="setMobilePage('channels')"
></div> class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to channels"
>
<ng-icon <ng-icon
name="lucideHash" name="lucideChevronLeft"
class="mx-auto mb-4 h-16 w-16 opacity-30" class="h-5 w-5"
/> />
<h2 </button>
data-theme-slot="text" <div class="min-w-0 flex-1">
class="mb-2 text-xl font-medium text-foreground" @if (activeChannel(); as channel) {
> <p class="flex min-w-0 items-center gap-1 truncate text-sm font-semibold text-foreground">
No text channels @if (channel.type === 'text') {
</h2> <ng-icon
<p class="text-sm">There are no existing text channels currently.</p> name="lucideHash"
class="h-4 w-4 shrink-0 text-muted-foreground"
/>
}
<span class="truncate">{{ channel.name }}</span>
</p>
} @else {
<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
type="button"
(click)="setMobilePage('members')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Show members"
>
<ng-icon
name="lucideUsers"
class="h-5 w-5"
/>
</button>
</div> </div>
}
}
<app-voice-workspace /> <main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
</main> @if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div>
} @else {
<div class="flex h-full items-center justify-center px-6">
<div class="max-w-md text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
<p class="text-sm">There are no existing text channels currently.</p>
</div>
</div>
}
}
<aside <app-voice-workspace />
appThemeNode="chatRoomMembersPanel" </main>
class="flex min-h-0 overflow-hidden border-l border-border bg-card" </div>
[ngStyle]="membersPanelLayoutStyles()" </swiper-slide>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-card">
<div class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
(click)="setMobilePage('main')"
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Back to chat"
>
<ng-icon
name="lucideChevronLeft"
class="h-5 w-5"
/>
</button>
<p class="truncate text-sm font-semibold text-foreground">Members</p>
</div>
<app-rooms-side-panel
panelMode="users"
[showVoiceControls]="false"
class="block h-full w-full"
/>
</div>
</swiper-slide>
</swiper-container>
} @else {
<!-- Desktop: theme-driven 3-pane grid layout -->
<div
class="grid min-h-0 flex-1 overflow-hidden"
[ngStyle]="roomLayoutStyles()"
> >
<app-rooms-side-panel <aside
panelMode="users" appThemeNode="chatRoomChannelsPanel"
[showVoiceControls]="false" class="flex min-h-0 overflow-hidden border-r border-border bg-card"
class="block h-full w-full" [ngStyle]="channelsPanelLayoutStyles()"
/> >
</aside> <app-rooms-side-panel
</div> panelMode="channels"
class="block h-full w-full"
/>
</aside>
<main
appThemeNode="chatRoomMainPanel"
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
[ngStyle]="mainPanelLayoutStyles()"
>
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
<app-chat-messages />
</div>
} @else {
<div
appThemeNode="chatRoomEmptyState"
class="flex h-full items-center justify-center px-6"
>
<div class="max-w-md text-center text-muted-foreground">
<div
data-theme-slot="icon"
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
></div>
<ng-icon
name="lucideHash"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2
data-theme-slot="text"
class="mb-2 text-xl font-medium text-foreground"
>
No text channels
</h2>
<p class="text-sm">There are no existing text channels currently.</p>
</div>
</div>
}
}
<app-voice-workspace />
</main>
<aside
appThemeNode="chatRoomMembersPanel"
class="flex min-h-0 overflow-hidden border-l border-border bg-card"
[ngStyle]="membersPanelLayoutStyles()"
>
<app-rooms-side-panel
panelMode="users"
[showVoiceControls]="false"
class="block h-full w-full"
/>
</aside>
</div>
}
} @else { } @else {
<div <div
appThemeNode="chatRoomEmptyState" appThemeNode="chatRoomEmptyState"
@@ -1,9 +1,14 @@
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
ElementRef,
NgZone,
computed, computed,
effect,
inject, inject,
signal signal,
viewChild
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -15,18 +20,44 @@ 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';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component'; import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component'; import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
import { ServersRailComponent } from '../../servers/servers-rail/servers-rail.component';
import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors'; import {
selectActiveChannelId,
selectCurrentRoom,
selectTextChannels
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
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. */
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
const PAGE_TO_INDEX: Record<ChatRoomMobilePage, number> = {
channels: 0,
main: 1,
members: 2
};
const INDEX_TO_PAGE: ChatRoomMobilePage[] = [
'channels',
'main',
'members'
];
interface SwiperElement extends HTMLElement {
swiper?: { activeIndex: number; slideTo: (index: number, speed?: number) => void };
}
@Component({ @Component({
selector: 'app-chat-room', selector: 'app-chat-room',
@@ -37,6 +68,7 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
ChatMessagesComponent, ChatMessagesComponent,
VoiceWorkspaceComponent, VoiceWorkspaceComponent,
RoomsSidePanelComponent, RoomsSidePanelComponent,
ServersRailComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
viewProviders: [ viewProviders: [
@@ -47,32 +79,154 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
lucideUsers, lucideUsers,
lucideMenu, lucideMenu,
lucideX, lucideX,
lucideChevronLeft lucideChevronLeft,
lucidePhoneCall
}) })
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './chat-room.component.html' templateUrl: './chat-room.component.html'
}) })
/** /**
* Main chat room view combining the messages panel, side panels, and admin controls. * Main chat room view combining the messages panel, side panels, and admin controls.
*
* On desktop the three panels (channels | main | members) are rendered side-by-side via the
* theme-driven grid layout. On mobile the same panels are rendered as Swiper slides
* (channels -> main -> members) so the user can swipe between them. `mobilePage`
* remains the source of truth and stays in sync with the active slide.
*/ */
export class ChatRoomComponent { export class ChatRoomComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
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 directCalls = inject(DirectCallService);
private readonly zone = inject(NgZone);
private voiceWorkspace = inject(VoiceWorkspaceService); private voiceWorkspace = inject(VoiceWorkspaceService);
private lastSeenChannelId: string | null = null;
private lastSeenRoomId: string | null = null;
private swiperListenerAttached: SwiperElement | null = null;
showMenu = signal(false); showMenu = signal(false);
showAdminPanel = signal(false); showAdminPanel = signal(false);
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
/**
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
* with the selected channel name instead of the room name.
*/
activeChannel = computed(() => {
const id = this.activeChannelId();
if (!id) {
return null;
}
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
});
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'));
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel')); membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
constructor() {
// When entering a server, always land on the channels list ("first page") on mobile, even
// if a default channel is pre-selected. Once inside the server, *changing* channels
// (i.e. user taps a channel in the list) advances to the main pane so the user sees the chat.
effect(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id ?? null;
const isRoomChange = roomId !== this.lastSeenRoomId;
this.lastSeenRoomId = roomId;
if (!this.isMobile()) {
this.lastSeenChannelId = channelId ?? null;
return;
}
if (isRoomChange) {
// New server: show the channels list and don't auto-advance.
this.lastSeenChannelId = channelId ?? null;
this.mobilePage.set('channels');
return;
}
if (channelId && channelId !== this.lastSeenChannelId) {
this.mobilePage.set('main');
}
this.lastSeenChannelId = channelId ?? null;
});
// Mirror `mobilePage` into the Swiper instance so back-button taps and the
// channel-selected auto-advance actually slide the carousel.
effect(() => {
const el = this.swiperRef()?.nativeElement;
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
el.swiper.slideTo(targetIndex);
}
});
// Bridge Swiper's slidechange event back into `mobilePage`.
effect((onCleanup) => {
const el = this.swiperRef()?.nativeElement;
if (!el || el === this.swiperListenerAttached) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent).detail;
const swiper = Array.isArray(detail) ? detail[0] : detail;
const index = swiper?.activeIndex ?? 0;
const page = INDEX_TO_PAGE[index] ?? 'channels';
this.zone.run(() => this.mobilePage.set(page));
};
el.addEventListener('swiperslidechange', handler);
this.swiperListenerAttached = el;
onCleanup(() => {
el.removeEventListener('swiperslidechange', handler);
if (this.swiperListenerAttached === el) {
this.swiperListenerAttached = null;
}
});
});
}
/** Set the active mobile page. No-op on desktop. */
setMobilePage(page: ChatRoomMobilePage): void {
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();
@@ -82,3 +236,4 @@ export class ChatRoomComponent {
} }
} }
} }
@@ -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) {
@@ -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">
@@ -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>;
}
@@ -257,6 +257,7 @@
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90" class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
[class.hidden]="isMobile()"
(click)="toggleScreenShare()" (click)="toggleScreenShare()"
> >
<ng-icon <ng-icon
@@ -43,6 +43,7 @@ import {
ScreenShareQuality, ScreenShareQuality,
ScreenShareStartOptions ScreenShareStartOptions
} from '../../../domains/screen-share'; } from '../../../domains/screen-share';
import { ViewportService } from '../../../core/platform';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
@@ -90,6 +91,8 @@ export class VoiceWorkspaceComponent {
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade); private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade); private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private readonly voicePlayback = inject(VoicePlaybackService); private readonly voicePlayback = inject(VoicePlaybackService);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService); private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceSession = inject(VoiceSessionFacade);
@@ -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"
@@ -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
@@ -1,8 +1,8 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity --> <!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
@if (isOpen() && !isThemeStudioFullscreen()) { @if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop --> <!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div <div
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200" class="fixed inset-0 z-[90] hidden bg-black/80 backdrop-blur-sm transition-opacity duration-200 md:block"
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="onBackdropClick()" (click)="onBackdropClick()"
@@ -13,15 +13,14 @@
aria-label="Close settings" aria-label="Close settings"
></div> ></div>
<!-- Modal --> <!-- Modal: full-screen page on mobile, centered dialog on desktop -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
<div <div
appThemeNode="settingsModalSurface" appThemeNode="settingsModalSurface"
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200" class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
style="height: min(720px, 88vh)"
[class.scale-100]="animating()" [class.scale-100]="animating()"
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.scale-95]="!animating()" [class.md:scale-95]="!animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
@@ -31,18 +30,32 @@
aria-labelledby="settings-modal-title" aria-labelledby="settings-modal-title"
tabindex="-1" tabindex="-1"
> >
<!-- Side Navigation --> <!-- Side Navigation: persistent on desktop; full-width "menu" page on mobile -->
<nav <nav
appThemeNode="settingsModalNav" appThemeNode="settingsModalNav"
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card" class="flex w-full flex-shrink-0 flex-col border-r border-border bg-card md:w-56"
[class.hidden]="isMobile() && mobilePage() !== 'menu'"
> >
<div class="border-b border-border px-3 py-3"> <div class="flex items-center justify-between border-b border-border px-3 py-3">
<h2 <h2
id="settings-modal-title" id="settings-modal-title"
class="text-lg font-semibold text-foreground" class="text-lg font-semibold text-foreground"
> >
Settings Settings
</h2> </h2>
@if (isMobile()) {
<button
(click)="close()"
type="button"
aria-label="Close settings"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
}
</div> </div>
<div class="flex-1 overflow-y-auto py-2"> <div class="flex-1 overflow-y-auto py-2">
@@ -52,8 +65,8 @@
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button" type="button"
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors" class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
[class.bg-secondary]="activePage() === page.id" [class.bg-secondary]="activePage() === page.id && !isMobile()"
[class.text-foreground]="activePage() === page.id" [class.text-foreground]="activePage() === page.id"
[class.font-medium]="activePage() === page.id" [class.font-medium]="activePage() === page.id"
[class.text-muted-foreground]="activePage() !== page.id" [class.text-muted-foreground]="activePage() !== page.id"
@@ -92,8 +105,8 @@
<button <button
(click)="navigate(page.id)" (click)="navigate(page.id)"
type="button" type="button"
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors" class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
[class.bg-secondary]="activePage() === page.id" [class.bg-secondary]="activePage() === page.id && !isMobile()"
[class.text-foreground]="activePage() === page.id" [class.text-foreground]="activePage() === page.id"
[class.font-medium]="activePage() === page.id" [class.font-medium]="activePage() === page.id"
[class.text-muted-foreground]="activePage() !== page.id" [class.text-muted-foreground]="activePage() !== page.id"
@@ -123,66 +136,85 @@
</div> </div>
</nav> </nav>
<!-- Content --> <!-- Content: shown alongside nav on desktop; full-width "detail" page on mobile -->
<div class="flex-1 flex flex-col min-w-0"> <div
class="flex flex-1 flex-col min-w-0"
[class.hidden]="isMobile() && mobilePage() !== 'detail'"
>
<!-- Header --> <!-- Header -->
<div <div
appThemeNode="settingsModalHeader" appThemeNode="settingsModalHeader"
class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0" class="flex items-center justify-between border-b border-border px-3 py-3 flex-shrink-0 md:px-5"
> >
<h3 class="text-lg font-semibold text-foreground"> <div class="flex min-w-0 items-center gap-1">
@switch (activePage()) { @if (isMobile()) {
@case ('general') { <button
General (click)="backToMenu()"
} type="button"
@case ('plugins') { aria-label="Back to settings menu"
Client Plugins class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
} >
@case ('network') { <ng-icon
Network name="lucideChevronLeft"
} class="w-5 h-5"
@case ('theme') { />
Theme Studio </button>
}
@case ('notifications') {
Notifications
}
@case ('voice') {
Voice & Audio
}
@case ('updates') {
Updates
}
@case ('localApi') {
Local API
}
@case ('data') {
Data
}
@case ('debugging') {
Debugging
}
@case ('server') {
Server Settings
}
@case ('serverPlugins') {
Server Plugins
}
@case ('members') {
Members
}
@case ('bans') {
Bans
}
@case ('permissions') {
Permissions
}
} }
</h3> <h3 class="truncate text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('general') {
General
}
@case ('plugins') {
Client Plugins
}
@case ('network') {
Network
}
@case ('theme') {
Theme Studio
}
@case ('notifications') {
Notifications
}
@case ('voice') {
Voice & Audio
}
@case ('updates') {
Updates
}
@case ('localApi') {
Local API
}
@case ('data') {
Data
}
@case ('debugging') {
Debugging
}
@case ('server') {
Server Settings
}
@case ('serverPlugins') {
Server Plugins
}
@case ('members') {
Members
}
@case ('bans') {
Bans
}
@case ('permissions') {
Permissions
}
}
</h3>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
(click)="close()" (click)="close()"
type="button" type="button"
aria-label="Close settings"
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
> >
<ng-icon <ng-icon
@@ -17,6 +17,7 @@ import {
lucideX, lucideX,
lucideBug, lucideBug,
lucideBell, lucideBell,
lucideChevronLeft,
lucideDownload, lucideDownload,
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
@@ -30,6 +31,7 @@ import {
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { ViewportService } from '../../../core/platform';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../core/realtime';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
@@ -83,6 +85,7 @@ import {
lucideX, lucideX,
lucideBug, lucideBug,
lucideBell, lucideBell,
lucideChevronLeft,
lucideDownload, lucideDownload,
lucideGlobe, lucideGlobe,
lucideAudioLines, lucideAudioLines,
@@ -103,9 +106,21 @@ export class SettingsModalComponent {
private webrtc = inject(RealtimeSessionFacade); private webrtc = inject(RealtimeSessionFacade);
private theme = inject(ThemeService); private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService); private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null; private lastRequestedServerId: string | null = null;
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
readonly isMobile = this.viewport.isMobile;
/**
* Active mobile sub-page within the settings flow.
* 'menu' -> the section list (nav)
* 'detail' -> the selected page content
* Ignored on desktop.
*/
readonly mobilePage = signal<'menu' | 'detail'>('menu');
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp'); private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
@@ -299,6 +314,11 @@ export class SettingsModalComponent {
} }
this.animating.set(true); this.animating.set(true);
// On mobile, always start on the section list so the user picks the page first.
if (this.isMobile()) {
this.mobilePage.set('menu');
}
}); });
effect(() => { effect(() => {
@@ -360,6 +380,12 @@ export class SettingsModalComponent {
} }
if (this.isOpen()) { if (this.isOpen()) {
// On mobile, Escape on the detail page just navigates back to the menu.
if (this.isMobile() && this.mobilePage() === 'detail') {
this.backToMenu();
return;
}
this.close(); this.close();
} }
} }
@@ -386,6 +412,16 @@ export class SettingsModalComponent {
navigate(page: SettingsPage): void { navigate(page: SettingsPage): void {
this.modal.navigate(page); this.modal.navigate(page);
// On mobile, advance to the detail page so the user sees the selected pane.
if (this.isMobile()) {
this.mobilePage.set('detail');
}
}
/** Go back to the section list on mobile. No-op on desktop. */
backToMenu(): void {
this.mobilePage.set('menu');
} }
openThemeStudio(): void { openThemeStudio(): void {
@@ -8,6 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../core/platform/viewport.service';
import { ContextMenuComponent } from '../../../shared'; import { ContextMenuComponent } from '../../../shared';
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models'; import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
@@ -55,11 +56,19 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly viewport = inject(ViewportService);
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null; private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
@HostListener('document:contextmenu', ['$event']) @HostListener('document:contextmenu', ['$event'])
onDocumentContextMenu(event: MouseEvent): void { onDocumentContextMenu(event: MouseEvent): void {
// On mobile (non-Electron), let the OS-native context menu handle text inputs,
// selection, links, and images. Intercepting here suppresses the OS menu and
// leaves the user without copy/paste/select-all affordances.
if (this.viewport.isMobile() && !this.electronBridge.isAvailable) {
return;
}
this.captureSelectionSnapshot(event); this.captureSelectionSnapshot(event);
if (this.electronBridge.isAvailable) { if (this.electronBridge.isAvailable) {
@@ -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[]
``` ```
@@ -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,
@@ -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); }
@@ -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[]> {
@@ -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
@@ -4,8 +4,8 @@ import {
computed, computed,
type Signal type Signal
} from '@angular/core'; } from '@angular/core';
import { environment } from '../../../environments/environment';
import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants'; import { STORAGE_KEY_ICE_SERVERS } from '../../core/constants';
import { ICE_SERVERS } from './realtime.constants';
export interface IceServerEntry { export interface IceServerEntry {
id: string; id: string;
@@ -15,7 +15,7 @@ export interface IceServerEntry {
credential?: string; credential?: string;
} }
const DEFAULT_ENTRIES: IceServerEntry[] = ICE_SERVERS.map((server, index) => ({ const DEFAULT_ENTRIES: IceServerEntry[] = environment.realtime.defaultIceServers.map((server, index) => ({
id: `default-stun-${index}`, id: `default-stun-${index}`,
type: 'stun' as const, type: 'stun' as const,
urls: Array.isArray(server.urls) ? server.urls[0] : server.urls urls: Array.isArray(server.urls) ? server.urls[0] : server.urls
@@ -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();
}); });
@@ -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,
@@ -5,14 +5,6 @@ import type { LatencyProfile } from '../../shared-kernel';
* Centralised here so nothing is hard-coded inline. * Centralised here so nothing is hard-coded inline.
*/ */
export const ICE_SERVERS: RTCIceServer[] = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ urls: 'stun:stun4.l.google.com:19302' }
];
/** Base delay (ms) for exponential backoff on signaling reconnect */ /** Base delay (ms) for exponential backoff on signaling reconnect */
export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000; export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000;
/** Maximum delay (ms) between signaling reconnect attempts */ /** Maximum delay (ms) between signaling reconnect attempts */
@@ -0,0 +1,46 @@
<!-- Dimmed backdrop. Tap to dismiss. -->
<div
class="fixed inset-0 z-[140] bg-black/40 backdrop-blur-sm"
(click)="onBackdropClick()"
(keydown.enter)="onBackdropClick()"
(keydown.space)="onBackdropClick()"
role="button"
tabindex="0"
aria-label="Close"
></div>
<!--
Bottom sheet panel. Slides up from the bottom of the viewport. Drag the top handle downward
beyond 80px to dismiss. Inner content is projected by the parent component.
-->
<div
appThemeNode="bottomSheetSurface"
class="bottom-sheet-panel fixed inset-x-0 bottom-0 z-[141] flex max-h-[85vh] flex-col rounded-t-2xl border-x border-t border-border bg-card text-foreground shadow-2xl"
[style.transform]="'translateY(' + translateY() + 'px)'"
[style.transition]="translateY() === 0 ? 'transform 200ms ease-out' : 'none'"
[attr.aria-label]="title() || ariaLabel()"
role="dialog"
aria-modal="true"
>
<!-- Drag handle + optional title -->
<div
class="flex shrink-0 cursor-grab touch-none flex-col items-center gap-2 px-4 pb-2 pt-3 active:cursor-grabbing"
(touchstart)="onHandleTouchStart($event)"
(touchmove)="onHandleTouchMove($event)"
(touchend)="onHandleTouchEnd()"
(touchcancel)="onHandleTouchEnd()"
>
<span
aria-hidden="true"
class="h-1.5 w-10 rounded-full bg-muted-foreground/40"
></span>
@if (title()) {
<h3 class="text-sm font-semibold text-foreground">{{ title() }}</h3>
}
</div>
<!-- Scrollable content area -->
<div class="min-h-0 flex-1 overflow-y-auto px-1 pb-[max(env(safe-area-inset-bottom),1rem)]">
<ng-content />
</div>
</div>
@@ -0,0 +1,27 @@
/*
* Bottom sheet slide-up animation. Applied on initial mount so the panel slides into view.
* Drag offsets are applied inline via [style.transform], which override this animation.
*/
:host {
display: contents;
}
.bottom-sheet-panel {
animation: bottom-sheet-slide-up 220ms ease-out;
}
@keyframes bottom-sheet-slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.bottom-sheet-panel {
animation: none;
}
}
@@ -0,0 +1,102 @@
import {
Component,
HostListener,
computed,
input,
output,
signal
} from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme';
/**
* Mobile bottom-sheet container.
*
* Renders a backdrop + a panel anchored to the bottom of the viewport that slides up from below.
* Intended for use on phone-sized viewports where context menus, action sheets, and confirmation
* dialogs are better presented as bottom sheets than as floating popovers or centered modals.
*
* The component is layout-only: callers project their content via `<ng-content>` and listen for
* the `dismissed` output to close themselves. Drag-to-dismiss is supported via touch gestures.
*
* Desktop callers should not render this component; use the original popover/modal layout instead.
*
* @example
* ```html
* @if (isMobile()) {
* <app-bottom-sheet (dismissed)="close()">
* <my-menu-items />
* </app-bottom-sheet>
* }
* ```
*/
@Component({
selector: 'app-bottom-sheet',
standalone: true,
imports: [ThemeNodeDirective],
templateUrl: './bottom-sheet.component.html',
styleUrl: './bottom-sheet.component.scss'
})
export class BottomSheetComponent {
/** Optional title rendered at the top of the sheet. Omit for an unlabeled action sheet. */
readonly title = input<string | null>(null);
/** Optional ARIA label when no visible title is provided. */
readonly ariaLabel = input<string>('Menu');
/** Emits when the user dismisses the sheet (backdrop tap, swipe-down, or Escape). */
readonly dismissed = output<undefined>();
/** Pixels the sheet is currently dragged downward. Drives the translate transform. */
protected readonly dragOffset = signal(0);
/** Visible transform offset in CSS pixels (only positive values move the sheet down). */
protected readonly translateY = computed(() => Math.max(0, this.dragOffset()));
private touchStartY: number | null = null;
@HostListener('document:keydown.escape')
protected onEscape(): void {
this.dismissed.emit(undefined);
}
protected onBackdropClick(): void {
this.dismissed.emit(undefined);
}
protected onHandleTouchStart(event: TouchEvent): void {
const touch = event.touches[0];
if (!touch) {
return;
}
this.touchStartY = touch.clientY;
}
protected onHandleTouchMove(event: TouchEvent): void {
if (this.touchStartY === null) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
const delta = touch.clientY - this.touchStartY;
// Only allow dragging downward; ignore upward drags.
this.dragOffset.set(Math.max(0, delta));
}
protected onHandleTouchEnd(): void {
// Dismiss if the user dragged the sheet down by more than 80px; otherwise snap back.
if (this.dragOffset() > 80) {
this.dismissed.emit(undefined);
}
this.touchStartY = null;
this.dragOffset.set(0);
}
}
@@ -1,46 +1,85 @@
<!-- Backdrop --> <!--
<div Two presentations:
class="fixed inset-0 z-40 bg-black/30" - Mobile: rendered through `app-bottom-sheet` so confirmations slide up from the bottom.
(click)="cancelled.emit(undefined)" - Desktop: original centered modal with backdrop.
(keydown.enter)="cancelled.emit(undefined)" -->
(keydown.space)="cancelled.emit(undefined)" @if (isMobile()) {
role="button" <app-bottom-sheet
tabindex="0" [title]="title()"
aria-label="Close dialog" [ariaLabel]="title()"
></div> (dismissed)="cancelled.emit(undefined)"
>
<!-- Dialog --> <div class="px-4 pb-3 pt-1 text-sm text-muted-foreground">
<div
appThemeNode="confirmDialogSurface"
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()"
>
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content /> <ng-content />
</div> </div>
<div class="flex gap-2 border-t border-border p-3">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="min-h-11 flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="min-h-11 flex-1 rounded-lg px-3 py-2 text-sm transition-colors"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</app-bottom-sheet>
} @else {
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog -->
<div
appThemeNode="confirmDialogSurface"
class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()"
>
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content />
</div>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div> </div>
<div class="flex gap-2 p-3 border-t border-border"> }
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div>
@@ -1,15 +1,18 @@
import { import {
Component, Component,
HostListener,
inject,
input, input,
output, output
HostListener
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
standalone: true, standalone: true,
imports: [ThemeNodeDirective], imports: [ThemeNodeDirective, BottomSheetComponent],
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'
@@ -24,6 +27,8 @@ export class ConfirmDialogComponent {
confirmed = output<undefined>(); confirmed = output<undefined>();
cancelled = output<undefined>(); cancelled = output<undefined>();
readonly isMobile = inject(ViewportService).isMobile;
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
this.cancelled.emit(undefined); this.cancelled.emit(undefined);
@@ -1,23 +1,40 @@
<!-- Invisible backdrop that captures clicks outside --> <!--
<div ContextMenu has two presentations:
class="fixed inset-0 z-40" - On phone-sized viewports the menu opens as a bottom sheet anchored to the bottom of the screen.
(click)="closed.emit(undefined)" - On desktop it remains an absolutely-positioned popover at the requested (x, y) coordinates.
(contextmenu)="$event.preventDefault(); closed.emit(undefined)" -->
(keydown.enter)="closed.emit(undefined)" @if (isMobile()) {
(keydown.space)="closed.emit(undefined)" <app-bottom-sheet
role="button" [title]="sheetTitle()"
tabindex="0" [ariaLabel]="sheetTitle() || 'Menu'"
aria-label="Close menu" (dismissed)="closed.emit(undefined)"
></div> >
<!-- Positioned menu panel --> <div class="flex flex-col py-1">
<div <ng-content />
#panel </div>
appThemeNode="contextMenuSurface" </app-bottom-sheet>
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1" } @else {
[class]="widthPx() ? '' : width()" <!-- Invisible backdrop that captures clicks outside -->
[style.left.px]="clampedX()" <div
[style.top.px]="clampedY()" class="fixed inset-0 z-40"
[style.width.px]="widthPx() || null" (click)="closed.emit(undefined)"
> (contextmenu)="$event.preventDefault(); closed.emit(undefined)"
<ng-content /> (keydown.enter)="closed.emit(undefined)"
</div> (keydown.space)="closed.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close menu"
></div>
<!-- Positioned menu panel -->
<div
#panel
appThemeNode="contextMenuSurface"
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="widthPx() ? '' : width()"
[style.left.px]="clampedX()"
[style.top.px]="clampedY()"
[style.width.px]="widthPx() || null"
>
<ng-content />
</div>
}
@@ -7,14 +7,17 @@ import {
ViewChild, ViewChild,
ElementRef, ElementRef,
AfterViewInit, AfterViewInit,
OnInit OnInit,
inject
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
standalone: true, standalone: true,
imports: [ThemeNodeDirective], imports: [ThemeNodeDirective, BottomSheetComponent],
templateUrl: './context-menu.component.html', templateUrl: './context-menu.component.html',
styleUrl: './context-menu.component.scss' styleUrl: './context-menu.component.scss'
}) })
@@ -24,19 +27,32 @@ export class ContextMenuComponent implements OnInit, AfterViewInit {
y = input.required<number>(); y = input.required<number>();
width = input<string>('w-48'); width = input<string>('w-48');
widthPx = input<number | null>(null); widthPx = input<number | null>(null);
/** Optional title shown when the menu is presented as a mobile bottom sheet. */
sheetTitle = input<string | null>(null);
closed = output<undefined>(); closed = output<undefined>();
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>; @ViewChild('panel', { static: false }) panelRef?: ElementRef<HTMLDivElement>;
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
clampedX = signal(0); clampedX = signal(0);
clampedY = signal(0); clampedY = signal(0);
ngOnInit(): void { ngOnInit(): void {
if (this.isMobile()) {
return;
}
this.clampedX.set(this.clampX(this.x(), this.estimateWidth())); this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
this.clampedY.set(this.clampY(this.y(), 80)); this.clampedY.set(this.clampY(this.y(), 80));
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.isMobile() || !this.panelRef) {
return;
}
const rect = this.panelRef.nativeElement.getBoundingClientRect(); const rect = this.panelRef.nativeElement.getBoundingClientRect();
this.clampedX.set(this.clampX(this.x(), rect.width)); this.clampedX.set(this.clampX(this.x(), rect.width));
@@ -0,0 +1,253 @@
<div
appThemeNode="profileCardSurface"
class="flex w-full flex-col bg-card text-foreground"
>
@let profileUser = displayedUser();
@let statusColor = currentStatusColor();
@let statusLabel = currentStatusLabel();
@let self = isSelf();
@let friend = isFriend();
@let isEditable = editable();
@let activeField = editingField();
<div
appThemeNode="profileCardBanner"
class="h-24 bg-gradient-to-r from-primary/30 to-primary/10"
></div>
<div class="-mt-16 flex flex-col items-center px-6">
<div class="relative">
<button
type="button"
class="rounded-full"
[disabled]="!isEditable || avatarSaving()"
(click)="pickAvatar(avatarInput)"
>
<app-user-avatar
[name]="profileUser.displayName"
[avatarUrl]="profileUser.avatarUrl"
size="2xl"
[status]="profileUser.status"
[showStatusBadge]="true"
ringClass="ring-4 ring-card"
/>
</button>
@if (isEditable) {
<span
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
name="lucideCamera"
class="h-4 w-4"
/>
</span>
}
<input
#avatarInput
type="file"
class="hidden"
[accept]="avatarAccept"
(change)="onAvatarSelected($event)"
/>
</div>
<div class="mt-3 w-full text-center">
@if (isEditable && activeField === 'displayName') {
<input
type="text"
class="w-full rounded-lg border border-border bg-background/70 px-3 py-2 text-center text-lg font-semibold text-foreground outline-none focus:border-primary/70"
[value]="displayNameDraft()"
(input)="onDisplayNameInput($event)"
(blur)="finishEdit('displayName')"
/>
} @else if (isEditable) {
<button
type="button"
class="w-full text-center text-xl font-semibold text-foreground hover:underline"
(click)="startEdit('displayName')"
>
{{ profileUser.displayName }}
</button>
} @else {
<h2 class="text-center text-xl font-semibold text-foreground">{{ profileUser.displayName }}</h2>
}
</div>
@if (profileUser.username && profileUser.username !== profileUser.displayName) {
<p class="mt-0.5 text-sm text-muted-foreground">{{ '@' + profileUser.username }}</p>
}
@if (isEditable) {
<div class="relative mt-3 w-full max-w-[14rem]">
<button
type="button"
class="flex w-full items-center gap-2 rounded-full border border-border bg-secondary/40 px-3 py-1.5 text-sm transition-colors hover:bg-secondary"
(click)="toggleStatusMenu()"
>
<span
class="h-2 w-2 rounded-full"
[class]="statusColor"
></span>
<span class="flex-1 text-left text-foreground">{{ statusLabel }}</span>
<ng-icon
name="lucideChevronDown"
class="h-3.5 w-3.5 text-muted-foreground"
/>
</button>
@if (showStatusMenu()) {
<div class="absolute left-0 right-0 top-full z-10 mt-1 rounded-lg border border-border bg-card py-1 shadow-lg">
@for (opt of statusOptions; track opt.label) {
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-secondary"
[class.bg-secondary]="isStatusOptionSelected(opt.value)"
[class.text-foreground]="isStatusOptionSelected(opt.value)"
[class.text-muted-foreground]="!isStatusOptionSelected(opt.value)"
(click)="setStatus(opt.value)"
>
<span
class="h-2 w-2 rounded-full"
[class]="opt.color"
></span>
<span class="flex-1">{{ opt.label }}</span>
@if (isStatusOptionSelected(opt.value)) {
<ng-icon
name="lucideCheck"
class="h-4 w-4 text-primary"
/>
}
</button>
}
</div>
}
</div>
} @else {
<div class="mt-2 inline-flex items-center gap-1.5 rounded-full bg-secondary/40 px-2.5 py-1 text-xs text-muted-foreground">
<span
class="h-2 w-2 rounded-full"
[class]="statusColor"
></span>
<span>{{ statusLabel }}</span>
</div>
}
</div>
<div class="mt-4 space-y-3 px-6 pb-2">
@if (isEditable) {
@if (activeField === 'description') {
<textarea
rows="3"
class="w-full resize-none rounded-lg border border-border bg-background/70 px-3 py-2 text-sm leading-5 text-foreground outline-none focus:border-primary/70"
[value]="descriptionDraft()"
placeholder="Add a description"
(input)="onDescriptionInput($event)"
(blur)="finishEdit('description')"
></textarea>
} @else {
<button
type="button"
class="block w-full rounded-lg border border-dashed border-border/70 bg-background/30 px-3 py-2 text-left text-sm leading-5"
(click)="startEdit('description')"
>
@if (profileUser.description) {
<span class="whitespace-pre-line text-muted-foreground">{{ profileUser.description }}</span>
} @else {
<span class="text-muted-foreground/70">Add a description</span>
}
</button>
}
} @else if (profileUser.description) {
<p class="whitespace-pre-line text-center text-sm leading-5 text-muted-foreground">
{{ profileUser.description }}
</p>
}
@if (avatarError()) {
<div class="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{{ avatarError() }}
</div>
}
@if (profileUser.gameActivity; as activity) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-xl border border-border bg-background/40 px-3 py-2 text-left"
[disabled]="!activity.store?.url"
(click)="openGameStore($event)"
>
@if (activity.iconUrl) {
<img
class="h-10 w-10 shrink-0 rounded-md object-cover"
[src]="activity.iconUrl"
alt=""
/>
} @else {
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<ng-icon
name="lucideGamepad2"
class="h-5 w-5"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">Playing {{ activity.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</button>
}
</div>
@if (!self) {
<div class="grid grid-cols-1 gap-2 px-6 pb-6 pt-4">
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="startChat()"
>
<ng-icon
name="lucideMessageCircle"
class="h-5 w-5"
/>
<span>Start chat</span>
</button>
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-secondary/40 px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="startCall()"
>
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
<span>Call</span>
</button>
<button
type="button"
class="inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-secondary/20 px-4 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
[disabled]="busy()"
(click)="toggleFriend()"
>
@if (friend) {
<ng-icon
name="lucideUserMinus"
class="h-5 w-5"
/>
<span>Remove friend</span>
} @else {
<ng-icon
name="lucideUserPlus"
class="h-5 w-5"
/>
<span>Add friend</span>
}
</button>
</div>
} @else {
<div class="px-6 pb-6 pt-2"></div>
}
</div>
@@ -0,0 +1,378 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
OnDestroy,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { ThemeNodeDirective } from '../../../domains/theme';
import { User, UserStatus } from '../../../shared-kernel';
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { UserStatusService } from '../../../core/services/user-status.service';
import {
EditableProfileAvatarSource,
PROFILE_AVATAR_ACCEPT_ATTRIBUTE,
ProcessedProfileAvatar,
ProfileAvatarEditorService,
ProfileAvatarFacade
} from '../../../domains/profile-avatar';
@Component({
selector: 'app-profile-card-mobile',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
})
],
templateUrl: './profile-card-mobile.component.html'
})
export class ProfileCardMobileComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly closed = output<undefined>();
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
readonly avatarError = signal<string | null>(null);
readonly avatarSaving = signal(false);
readonly editingField = signal<'displayName' | 'description' | null>(null);
readonly displayNameDraft = signal('');
readonly descriptionDraft = signal('');
readonly showStatusMenu = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
{ value: 'away', label: 'Away', color: 'bg-yellow-500' },
{ value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' },
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
];
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly friendsService = inject(FriendService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly users = this.store.selectSignal(selectUsersEntities);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly displayedUser = computed(() => {
const snapshot = this.user();
const entities = this.users();
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId];
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
});
readonly isSelf = computed(() => {
const me = this.currentUser();
const them = this.displayedUser();
if (!me)
return false;
return me.id === them.id || me.oderId === them.oderId;
});
readonly isFriend = computed(() => this.friendsService.friendIds().has(this.displayedUser().id));
readonly activityNow = signal(Date.now());
readonly busy = signal(false);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
const editingField = this.editingField();
if (editingField !== 'displayName') {
this.displayNameDraft.set(user.displayName || '');
}
if (editingField !== 'description') {
this.descriptionDraft.set(user.description || '');
}
},
{ allowSignalWrites: true }
);
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
currentStatusColor(): string {
switch (this.displayedUser().status) {
case 'online':
return 'bg-green-500';
case 'away':
return 'bg-yellow-500';
case 'busy':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
}
currentStatusLabel(): string {
switch (this.displayedUser().status) {
case 'online':
return 'Online';
case 'away':
return 'Away';
case 'busy':
return 'Do Not Disturb';
case 'offline':
return 'Invisible';
case 'disconnected':
return 'Offline';
default:
return 'Online';
}
}
gameActivityElapsed(): string {
const activity = this.displayedUser().gameActivity;
return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : '';
}
openGameStore(event: Event): void {
event.stopPropagation();
const url = this.displayedUser().gameActivity?.store?.url;
if (url) {
this.externalLinks.open(url);
}
}
toggleStatusMenu(): void {
this.showStatusMenu.update((open) => !open);
}
setStatus(status: UserStatus | null): void {
this.userStatus.setManualStatus(status);
this.showStatusMenu.set(false);
}
isStatusOptionSelected(status: UserStatus | null): boolean {
const currentStatus = this.displayedUser().status;
return status === null ? currentStatus === 'online' : currentStatus === status;
}
onDisplayNameInput(event: Event): void {
this.displayNameDraft.set((event.target as HTMLInputElement).value);
}
onDescriptionInput(event: Event): void {
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
}
startEdit(field: 'displayName' | 'description'): void {
if (!this.editable() || this.editingField() === field) {
return;
}
this.editingField.set(field);
}
finishEdit(field: 'displayName' | 'description'): void {
if (this.editingField() !== field) {
return;
}
this.commitProfileDrafts();
this.editingField.set(null);
}
pickAvatar(fileInput: HTMLInputElement): void {
if (!this.editable() || this.avatarSaving()) {
return;
}
this.avatarError.set(null);
fileInput.click();
}
async onAvatarSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
let source: EditableProfileAvatarSource | null = null;
input.value = '';
if (!file) {
return;
}
const validationError = this.profileAvatar.validateFile(file);
if (validationError) {
this.avatarError.set(validationError);
return;
}
try {
source = await this.profileAvatar.prepareEditableSource(file);
const avatar = await this.profileAvatarEditor.open(source);
if (!avatar) {
return;
}
await this.applyAvatar(avatar);
} catch {
this.avatarError.set('Failed to open selected image.');
} finally {
this.profileAvatar.releaseEditableSource(source);
}
}
async applyAvatar(avatar: ProcessedProfileAvatar): Promise<void> {
const currentUser = this.displayedUser();
this.avatarSaving.set(true);
this.avatarError.set(null);
try {
await this.profileAvatar.persistProcessedAvatar(currentUser, avatar);
const updates = this.profileAvatar.buildAvatarUpdates(avatar);
this.store.dispatch(UsersActions.updateCurrentUserAvatar({ avatar: updates }));
this.user.update((user) => ({
...user,
...updates
}));
} catch {
this.avatarError.set('Failed to save profile image.');
} finally {
this.avatarSaving.set(false);
}
}
async startChat(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
const conversation = await this.directMessages.createConversation(this.displayedUser());
await this.router.navigate(['/dm', conversation.id]);
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async startCall(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.directCalls.startCall(this.displayedUser());
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async toggleFriend(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.friendsService.toggleFriend(this.displayedUser().id);
} finally {
this.busy.set(false);
}
}
private commitProfileDrafts(): void {
if (!this.editable()) {
return;
}
const displayName = this.normalizeDisplayName(this.displayNameDraft());
if (!displayName) {
this.displayNameDraft.set(this.user().displayName || '');
return;
}
const user = this.displayedUser();
const description = this.normalizeDescription(this.descriptionDraft());
if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) {
return;
}
const profile = {
displayName,
description,
profileUpdatedAt: Date.now()
};
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
this.user.update((user) => ({
...user,
...profile
}));
}
private normalizeDisplayName(value: string | undefined): string {
return value?.trim().replace(/\s+/g, ' ') || '';
}
private normalizeDescription(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized || undefined;
}
}
@@ -15,7 +15,9 @@ import {
fromEvent fromEvent
} from 'rxjs'; } from 'rxjs';
import { ProfileCardComponent } from './profile-card.component'; import { ProfileCardComponent } from './profile-card.component';
import { ProfileCardMobileComponent } from './profile-card-mobile.component';
import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar'; import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar';
import { ViewportService } from '../../../core/platform';
import { User } from '../../../shared-kernel'; import { User } from '../../../shared-kernel';
export type ProfileCardPlacement = 'above' | 'left' | 'auto'; export type ProfileCardPlacement = 'above' | 'left' | 'auto';
@@ -57,6 +59,7 @@ function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProfileCardService { export class ProfileCardService {
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly viewport = inject(ViewportService);
private overlayRef: OverlayRef | null = null; private overlayRef: OverlayRef | null = null;
private currentOrigin: HTMLElement | null = null; private currentOrigin: HTMLElement | null = null;
private outsideClickSub: Subscription | null = null; private outsideClickSub: Subscription | null = null;
@@ -76,23 +79,57 @@ export class ProfileCardService {
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin); const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
const placement = options.placement ?? 'auto'; const placement = options.placement ?? 'auto';
const isMobile = this.viewport.isMobile();
this.currentOrigin = rawEl; this.currentOrigin = rawEl;
const positionStrategy = this.overlay if (isMobile) {
.position() const positionStrategy = this.overlay
.flexibleConnectedTo(elementRef) .position()
.withPositions(positionsFor(placement)) .global()
.withViewportMargin(VIEWPORT_MARGIN) .left('0')
.withPush(true); .right('0')
.bottom('0');
this.overlayRef = this.overlay.create({ this.overlayRef = this.overlay.create({
positionStrategy, positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop() scrollStrategy: this.overlay.scrollStrategies.block(),
}); hasBackdrop: true,
backdropClass: 'cdk-overlay-dark-backdrop',
panelClass: 'metoyou-bottom-sheet-panel'
});
} else {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(elementRef)
.withPositions(positionsFor(placement))
.withViewportMargin(VIEWPORT_MARGIN)
.withPush(true);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.noop()
});
}
this.syncThemeVars(); this.syncThemeVars();
if (isMobile) {
const portal = new ComponentPortal(ProfileCardMobileComponent);
const ref = this.overlayRef.attach(portal);
ref.instance.user.set(user);
ref.instance.editable.set(options.editable ?? false);
const subscription = new Subscription();
subscription.add(ref.instance.closed.subscribe(() => this.close()));
subscription.add(this.overlayRef.backdropClick().subscribe(() => this.close()));
this.outsideClickSub = subscription;
return;
}
const portal = new ComponentPortal(ProfileCardComponent); const portal = new ComponentPortal(ProfileCardComponent);
const ref = this.overlayRef.attach(portal); const ref = this.overlayRef.attach(portal);
@@ -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';
} }
} }
} }
+1
View File
@@ -2,6 +2,7 @@
* Shared reusable UI components barrel. * Shared reusable UI components barrel.
*/ */
export { ContextMenuComponent } from './components/context-menu/context-menu.component'; export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { BottomSheetComponent } from './components/bottom-sheet/bottom-sheet.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component'; export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component'; export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component'; export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
@@ -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
}); });
@@ -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));
} }
@@ -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
});
}
}
})
);
}) })
); );
}) })
@@ -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 }>(),
@@ -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(
@@ -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;
} }
@@ -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: {}
}) })
) )
); );
@@ -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,
@@ -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
}; };
+13 -3
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()])
))
) )
); );
+25 -14
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,

Some files were not shown because too many files have changed in this diff Show More