Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 232a9ea8ea | |||
| 54e8b9a5e4 | |||
| 94428ed170 | |||
| afb64520ed | |||
| 0152ed9dd2 | |||
| dea114aed0 |
@@ -19,11 +19,11 @@ Capabilities protect privileged app surfaces. A plugin must declare a capability
|
||||
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
|
||||
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
|
||||
| `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.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.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.media` | Reserved peer media features. | Included for media-facing plugins. |
|
||||
| `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 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.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 () => {
|
||||
@@ -150,7 +149,7 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
|
||||
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
|
||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
||||
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 page.getByRole('button', { name: 'Install and Activate' }).click();
|
||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -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.getByRole('button', { name: 'Add Source' }).click();
|
||||
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 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' });
|
||||
|
||||
await expect(installDialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -90,10 +90,7 @@ test.describe('Direct private calls', () => {
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
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 answerIncomingCall(scenario.bob.page);
|
||||
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
|
||||
|
||||
await expect
|
||||
@@ -176,10 +173,7 @@ test.describe('Direct private calls', () => {
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await charlie.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
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 answerIncomingCall(charlie.page);
|
||||
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
|
||||
|
||||
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
|
||||
@@ -345,12 +339,7 @@ test.describe('Direct private calls', () => {
|
||||
})
|
||||
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
|
||||
|
||||
await scenario.bob.page
|
||||
.getByRole('button', { name: 'Open private call' })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await answerIncomingCall(scenario.bob.page);
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
|
||||
timeout: 10_000,
|
||||
@@ -358,12 +347,7 @@ test.describe('Direct private calls', () => {
|
||||
})
|
||||
.toBe(0);
|
||||
|
||||
await scenario.charlie.page
|
||||
.getByRole('button', { name: 'Open private call' })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await scenario.charlie.page.getByRole('button', { name: 'Join call' }).click();
|
||||
await answerIncomingCall(scenario.charlie.page);
|
||||
await expect
|
||||
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
|
||||
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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
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 answerIncomingCall(scenario.bob.page);
|
||||
|
||||
await waitForConnectedPeerCount(scenario.alice.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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
await expect(scenario.bob.page).toHaveURL(/\/call\//, { timeout: 20_000 });
|
||||
await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
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.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
|
||||
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 startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
|
||||
await scenario.bob.page.getByRole('button', { name: 'Open private call' }).click();
|
||||
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 answerIncomingCall(scenario.bob.page);
|
||||
|
||||
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
|
||||
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 });
|
||||
}
|
||||
|
||||
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> {
|
||||
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
|
||||
}
|
||||
|
||||
@@ -7,20 +7,36 @@ import { getCurrentUserScope } from '../../current-user-scope';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
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);
|
||||
|
||||
if (!currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await repo.find({
|
||||
where: { roomId, ownerUserId: currentUserId },
|
||||
order: { timestamp: 'ASC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
const rowsQuery = repo.createQueryBuilder('message')
|
||||
.where('message.roomId = :roomId', { roomId })
|
||||
.andWhere('message.ownerUserId = :currentUserId', { currentUserId })
|
||||
.orderBy('message.timestamp', 'DESC')
|
||||
.take(limit)
|
||||
.skip(offset);
|
||||
|
||||
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) ?? []));
|
||||
}
|
||||
|
||||
@@ -230,7 +230,16 @@ export type Command =
|
||||
| SaveMetaCommand
|
||||
| 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 GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||
|
||||
@@ -41,7 +41,14 @@ const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||
'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> {
|
||||
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> {
|
||||
const snapshot = Buffer.from(data);
|
||||
const saveTask = saveQueue.then(
|
||||
() => writeDatabaseSnapshot(snapshot),
|
||||
() => writeDatabaseSnapshot(snapshot)
|
||||
);
|
||||
|
||||
saveQueue = saveTask.catch(() => {});
|
||||
|
||||
return saveTask;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pendingSaveSnapshot = snapshot;
|
||||
pendingSaveWaiters.push({ resolve, reject });
|
||||
void drainDatabaseSaveQueue();
|
||||
});
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
|
||||
@@ -62,6 +62,9 @@ import { listRunningProcessNames } from '../process-list';
|
||||
import { detectActiveGame } from '../game-detection';
|
||||
|
||||
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 = [
|
||||
'x-special/gnome-copied-files',
|
||||
'text/uri-list',
|
||||
@@ -399,9 +402,16 @@ export function setupSystemHandlers(): void {
|
||||
icon: getWindowIconPath(),
|
||||
silent: true
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
const cleanup = () => {
|
||||
notification.removeListener('click', handleClick);
|
||||
notification.removeListener('close', cleanup);
|
||||
notification.removeListener('failed', cleanup);
|
||||
activeDesktopNotifications.delete(notification);
|
||||
desktopNotificationCleanups.delete(notification);
|
||||
};
|
||||
const handleClick = () => {
|
||||
if (!mainWindow) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,7 +424,26 @@ export function setupSystemHandlers(): void {
|
||||
}
|
||||
|
||||
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();
|
||||
} catch {
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -50,6 +50,7 @@
|
||||
"rxjs": "~7.8.0",
|
||||
"simple-peer": "^9.11.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"swiper": "^12.1.4",
|
||||
"tslib": "^2.3.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -31843,6 +31844,25 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"rxjs": "~7.8.0",
|
||||
"simple-peer": "^9.11.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"swiper": "^12.1.4",
|
||||
"tslib": "^2.3.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
Binary file not shown.
@@ -3,14 +3,16 @@
|
||||
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
|
||||
>
|
||||
<div
|
||||
class="grid h-full min-h-0 min-w-0 overflow-hidden"
|
||||
[ngStyle]="appShellLayoutStyles()"
|
||||
class="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
[class.grid]="!isMobile()"
|
||||
[class.flex]="isMobile()"
|
||||
[ngStyle]="isMobile() ? null : appShellLayoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="serversRail"
|
||||
class="min-h-0 overflow-hidden bg-transparent"
|
||||
[class.hidden]="isThemeStudioFullscreen()"
|
||||
[ngStyle]="serversRailLayoutStyles()"
|
||||
[class.hidden]="isThemeStudioFullscreen() || isMobile()"
|
||||
[ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
|
||||
>
|
||||
<app-servers-rail class="block h-full" />
|
||||
</aside>
|
||||
@@ -18,9 +20,12 @@
|
||||
<main
|
||||
appThemeNode="appWorkspace"
|
||||
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()"
|
||||
>
|
||||
@if (!isMobile()) {
|
||||
<app-title-bar class="block shrink-0" />
|
||||
}
|
||||
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||
@if (isThemeStudioFullscreen()) {
|
||||
@@ -88,6 +93,16 @@
|
||||
</main>
|
||||
</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()) {
|
||||
<div
|
||||
#themeStudioControlsRef
|
||||
|
||||
@@ -30,7 +30,7 @@ import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { NotificationsFacade } from './domains/notifications';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
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 { ElectronBridgeService } from './core/platform/electron/electron-bridge.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 { DirectCallService } from './domains/direct-call';
|
||||
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 { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareSourcePickerComponent,
|
||||
NativeContextMenuComponent,
|
||||
PrivateCallComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent
|
||||
],
|
||||
@@ -99,6 +101,8 @@ export class App implements OnInit, OnDestroy {
|
||||
readonly theme = inject(ThemeService);
|
||||
readonly voiceSession = inject(VoiceSessionFacade);
|
||||
readonly externalLinks = inject(ExternalLinkService);
|
||||
readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly electronBridge = inject(ElectronBridgeService);
|
||||
readonly userStatus = inject(UserStatusService);
|
||||
readonly gameActivity = inject(GameActivityService);
|
||||
@@ -263,12 +267,23 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
if (!currentUserId) {
|
||||
if (!this.isPublicRoute(currentUrl)) {
|
||||
// On mobile, new/unauthenticated visitors landing on the app root or
|
||||
// /search should stay on /search (which already exposes a login CTA).
|
||||
// The login form has no mobile chrome / back button, so dropping new
|
||||
// users straight onto it leaves them with no way to navigate away.
|
||||
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 {
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './platform.service';
|
||||
export * from './external-link.service';
|
||||
export * from './viewport.service';
|
||||
|
||||
69
toju-app/src/app/core/platform/viewport.service.ts
Normal file
69
toju-app/src/app/core/platform/viewport.service.ts
Normal file
@@ -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 isDatabaseInitialised = false;
|
||||
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
|
||||
}
|
||||
|
||||
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!roomId || !this.isRoomWatched(roomId))
|
||||
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
|
||||
return;
|
||||
|
||||
if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
if (activeRequest) {
|
||||
return activeRequest;
|
||||
}
|
||||
|
||||
return;
|
||||
const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
|
||||
if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
|
||||
this.autoDownloadRequestsByRoom.delete(roomId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
}
|
||||
}
|
||||
this.autoDownloadRequestsByRoom.set(roomId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
|
||||
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> {
|
||||
if (!messageId)
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/** Maximum number of recent messages to include in sync inventories. */
|
||||
export const INVENTORY_LIMIT = 1000;
|
||||
/** Maximum number of messages to include in sync inventories.
|
||||
*
|
||||
* 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. */
|
||||
export const CHUNK_SIZE = 200;
|
||||
@@ -14,7 +20,7 @@ export const SYNC_POLL_SLOW_MS = 900_000;
|
||||
export const SYNC_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** 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. */
|
||||
export interface InventoryItem {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
[isAdmin]="isAdmin()"
|
||||
[bottomPadding]="composerBottomPadding()"
|
||||
[conversationKey]="conversationKey()"
|
||||
[loadingOlder]="loadingOlder()"
|
||||
[conversationExhausted]="conversationExhausted()"
|
||||
(replyRequested)="setReplyTo($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
@@ -20,6 +22,7 @@
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
(loadOlderRequested)="handleLoadOlderRequested($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -40,6 +43,17 @@
|
||||
</div>
|
||||
|
||||
@if (showKlipyGifPicker()) {
|
||||
@if (isMobile()) {
|
||||
<app-bottom-sheet (dismissed)="closeKlipyGifPicker()">
|
||||
<div appThemeNode="chatGifPickerSurface">
|
||||
<app-klipy-gif-picker
|
||||
[signalSource]="currentRoom()"
|
||||
(gifSelected)="handleKlipyGifSelected($event)"
|
||||
(closed)="closeKlipyGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
</app-bottom-sheet>
|
||||
} @else {
|
||||
<div
|
||||
class="fixed inset-0 z-[89]"
|
||||
(click)="closeKlipyGifPicker()"
|
||||
@@ -66,6 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<app-chat-message-overlays
|
||||
[lightboxAttachment]="lightboxAttachment()"
|
||||
|
||||
@@ -8,15 +8,21 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent } from '../../../../shared';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectConversationExhausted,
|
||||
selectMessagesLoading,
|
||||
selectMessagesLoadingOlder,
|
||||
selectMessagesSyncing
|
||||
} from '../../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||
@@ -45,6 +51,7 @@ import {
|
||||
KlipyGifPickerComponent,
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
BottomSheetComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
@@ -59,6 +66,9 @@ export class ChatMessagesComponent {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
@@ -66,6 +76,7 @@ export class ChatMessagesComponent {
|
||||
|
||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
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 conversationExhausted = toSignal(
|
||||
toObservable(this.conversationKey).pipe(
|
||||
switchMap((key) => this.store.select(selectConversationExhausted(key)))
|
||||
),
|
||||
{ initialValue: false }
|
||||
);
|
||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||
readonly composerBottomPadding = signal(140);
|
||||
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 {
|
||||
const nextState = !this.showKlipyGifPicker();
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
[attr.data-message-id]="msg.id"
|
||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.opacity-50]="msg.isDeleted"
|
||||
(touchstart)="onMessageTouchStart($event)"
|
||||
(touchend)="onMessageTouchEnd()"
|
||||
(touchmove)="onMessageTouchEnd()"
|
||||
(touchcancel)="onMessageTouchEnd()"
|
||||
>
|
||||
<div
|
||||
appThemeNode="chatMessageAvatar"
|
||||
@@ -469,7 +473,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!msg.isDeleted) {
|
||||
@if (!msg.isDeleted && !isMobile()) {
|
||||
<div
|
||||
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"
|
||||
@@ -534,4 +538,83 @@
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -2,21 +2,28 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
signal,
|
||||
ViewChild
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
ViewContainerRef
|
||||
} from '@angular/core';
|
||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { TemplatePortal } from '@angular/cdk/portal';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideCopy,
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
@@ -34,7 +41,7 @@ import {
|
||||
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
import { PlatformService } from '../../../../../../core/platform';
|
||||
import { PlatformService, ViewportService } from '../../../../../../core/platform';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import {
|
||||
ExperimentalMediaSettingsService
|
||||
@@ -52,6 +59,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin
|
||||
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
|
||||
|
||||
import {
|
||||
BottomSheetComponent,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
ProfileCardService,
|
||||
@@ -125,11 +133,13 @@ interface MissingPluginEmbedFallback {
|
||||
UserAvatarComponent,
|
||||
PluginRenderHostComponent,
|
||||
ExperimentalVlcPlayerComponent,
|
||||
ThemeNodeDirective
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
lucideCopy,
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
@@ -144,12 +154,14 @@ interface MissingPluginEmbedFallback {
|
||||
],
|
||||
templateUrl: './chat-message-item.component.html',
|
||||
styleUrl: './chat-message-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ChatMessageItemComponent {
|
||||
export class ChatMessageItemComponent implements OnDestroy {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
@@ -160,6 +172,13 @@ export class ChatMessageItemComponent {
|
||||
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
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 experimentalPlayerAttachmentId = signal<string | null>(null);
|
||||
private readonly mediaSupportCache = new Map<string, boolean>();
|
||||
@@ -360,6 +379,116 @@ export class ChatMessageItemComponent {
|
||||
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 {
|
||||
this.embedRemoved.emit({
|
||||
messageId: this.message().id,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewChecked,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
@@ -48,6 +49,7 @@ declare global {
|
||||
ThemeNodeDirective
|
||||
],
|
||||
templateUrl: './chat-message-list.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
@@ -82,6 +84,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
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;
|
||||
|
||||
@@ -141,6 +153,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
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 bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
@@ -150,12 +177,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
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(() => {
|
||||
void this.conversationKey();
|
||||
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(() => {
|
||||
const currentCount = this.channelMessages().length;
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
@@ -170,6 +226,36 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
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 newMessages = currentCount > this.lastMessageCount;
|
||||
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
|
||||
@@ -232,7 +318,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
if (!messageId)
|
||||
return undefined;
|
||||
|
||||
return this.allMessages().find((message) => message.id === messageId);
|
||||
return this.messagesById().get(messageId);
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
@@ -252,15 +338,26 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.stopBottomScrollWatch();
|
||||
}
|
||||
|
||||
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
if (element.scrollTop < 150 && !this.loadingMore()) {
|
||||
const canFetchOlderFromDb =
|
||||
!this.hasMoreMessages()
|
||||
&& !this.conversationExhausted()
|
||||
&& !this.loadingOlder()
|
||||
&& this.channelMessages().length > 0;
|
||||
|
||||
if (this.hasMoreMessages() || canFetchOlderFromDb) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
if (this.loadingMore())
|
||||
return;
|
||||
|
||||
// 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;
|
||||
@@ -279,6 +376,31 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
readLatest(): void {
|
||||
@@ -359,6 +481,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
this.pendingOlderFetchScrollHeight = null;
|
||||
this.loadingMore.set(false);
|
||||
}
|
||||
|
||||
private startBottomScrollWatch(): void {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
aria-label="KLIPY GIF picker"
|
||||
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
|
||||
>
|
||||
@if (!isMobile()) {
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
|
||||
@@ -25,6 +26,7 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
|
||||
<label class="relative block">
|
||||
@@ -37,7 +39,7 @@
|
||||
type="text"
|
||||
[ngModel]="searchQuery"
|
||||
(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"
|
||||
/>
|
||||
</label>
|
||||
@@ -80,12 +82,14 @@
|
||||
</div>
|
||||
</div>
|
||||
} @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) {
|
||||
<button
|
||||
type="button"
|
||||
(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
|
||||
class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
|
||||
@@ -104,18 +108,42 @@
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
@if (!isMobile()) {
|
||||
<div class="px-3 py-2">
|
||||
<p class="truncate text-xs font-medium text-foreground">
|
||||
{{ gif.title || 'KLIPY GIF' }}
|
||||
</p>
|
||||
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</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>
|
||||
|
||||
@if (!isMobile()) {
|
||||
<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>
|
||||
|
||||
@@ -130,4 +158,5 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideChevronDown,
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||
import type { RoomSignalSourceInput } from '../../../server-directory';
|
||||
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
|
||||
const KLIPY_CARD_MIN_WIDTH = 140;
|
||||
const KLIPY_CARD_MAX_WIDTH = 248;
|
||||
@@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideChevronDown,
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
@@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private currentPage = 1;
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private requestId = 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
@@ -38,9 +39,11 @@ export class DirectCallService {
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
|
||||
private readonly mobileOverlayCallId = signal<string | null>(null);
|
||||
|
||||
readonly sessions = computed(() => this.sessionsSignal());
|
||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||
@@ -65,6 +68,15 @@ export class DirectCallService {
|
||||
});
|
||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||
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() {
|
||||
this.delivery.directCallEvents$.subscribe((event) => {
|
||||
@@ -92,6 +104,12 @@ export class DirectCallService {
|
||||
|
||||
this.audio.stop(AppSound.Call);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
|
||||
this.mobileOverlayCallId.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sessionById(callId: string | null | undefined): DirectCallSession | null {
|
||||
@@ -155,7 +173,7 @@ export class DirectCallService {
|
||||
this.currentSession.set(session);
|
||||
await this.joinCall(session.callId, false);
|
||||
this.sendCallEvent(peerParticipant.userId, 'ring', session);
|
||||
await this.router.navigate(['/call', session.callId]);
|
||||
await this.openCallView(session.callId);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -186,6 +204,24 @@ export class DirectCallService {
|
||||
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> {
|
||||
const session = this.sessionById(callId);
|
||||
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
appThemeNode="dmChatHeader"
|
||||
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
|
||||
>
|
||||
@if (peerUser()) {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
[attr.aria-label]="'Open profile for ' + peerName()"
|
||||
[title]="'Open profile for ' + peerName()"
|
||||
(click)="openHeaderProfileCard($event)"
|
||||
>
|
||||
<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">Direct Message</p>
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerUser()?.avatarUrl"
|
||||
@@ -17,6 +38,7 @@
|
||||
<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()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -97,6 +119,16 @@
|
||||
</div>
|
||||
|
||||
@if (showGifPicker()) {
|
||||
@if (isMobile()) {
|
||||
<app-bottom-sheet (dismissed)="closeGifPicker()">
|
||||
<div appThemeNode="chatGifPickerSurface">
|
||||
<app-klipy-gif-picker
|
||||
(gifSelected)="handleGifSelected($event)"
|
||||
(closed)="closeGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
</app-bottom-sheet>
|
||||
} @else {
|
||||
<div
|
||||
class="fixed inset-0 z-[89]"
|
||||
tabindex="0"
|
||||
@@ -121,6 +153,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<app-chat-message-overlays
|
||||
[lightboxAttachment]="lightboxAttachment()"
|
||||
|
||||
@@ -15,7 +15,12 @@ import { Store } from '@ngrx/store';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
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 { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
@@ -61,6 +66,7 @@ interface DmStatusLabel {
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
KlipyGifPickerComponent,
|
||||
BottomSheetComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
UserAvatarComponent
|
||||
@@ -80,8 +86,11 @@ export class DmChatComponent {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly profileCard = inject(ProfileCardService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly metadataRequestKeys = new Set<string>();
|
||||
private openedConversationId: string | null = null;
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
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 {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<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"
|
||||
aria-label="Direct Messages"
|
||||
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
class="h-[18px] w-[18px] md:h-4 md:w-4"
|
||||
/>
|
||||
@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>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<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-out]="item.isExiting"
|
||||
[class.pointer-events-none]="item.isExiting"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<main
|
||||
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()"
|
||||
>
|
||||
<app-dm-chat />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div
|
||||
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.text-foreground]="isSelected()"
|
||||
[attr.aria-current]="isSelected() ? 'page' : null"
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -48,6 +49,7 @@ export class DmConversationItemComponent {
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
readonly conversation = input.required<DirectMessageConversation>();
|
||||
readonly conversationOpened = output<string>();
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
@@ -71,6 +73,7 @@ export class DmConversationItemComponent {
|
||||
}
|
||||
|
||||
openConversation(): void {
|
||||
this.conversationOpened.emit(this.conversation().id);
|
||||
void this.router.navigate(['/dm', this.conversation().id]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<aside
|
||||
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()"
|
||||
>
|
||||
<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>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
|
||||
<app-dm-conversation-item
|
||||
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
|
||||
[conversation]="conversation"
|
||||
></app-dm-conversation-item>
|
||||
(conversationOpened)="conversationSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
inject,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -31,6 +32,7 @@ export class DmConversationsPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly conversationSelected = output<string>();
|
||||
|
||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||
return conversation.id;
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
@if (isMobile()) {
|
||||
<!-- Mobile: Swiper-driven page stack (conversations -> chat) -->
|
||||
<swiper-container
|
||||
#swiperEl
|
||||
class="block h-full min-h-0 w-full bg-background"
|
||||
slides-per-view="1"
|
||||
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()"
|
||||
@@ -5,3 +66,4 @@
|
||||
<app-dm-conversations-panel />
|
||||
<app-dm-chat-panel />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,46 +1,107 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
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 { DirectCallService } from '../../../direct-call';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmChatPanelComponent } from './dm-chat-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({
|
||||
selector: 'app-dm-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DmChatPanelComponent,
|
||||
DmConversationsPanelComponent
|
||||
DmConversationsPanelComponent,
|
||||
ServersRailComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronLeft, lucidePhoneCall })],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: './dm-workspace.component.html'
|
||||
})
|
||||
export class DmWorkspaceComponent implements OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
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 routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
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() {
|
||||
effect(() => {
|
||||
const conversationId = this.routeConversationId();
|
||||
const isMobile = this.isMobile();
|
||||
|
||||
if (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;
|
||||
}
|
||||
|
||||
@@ -50,9 +111,63 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
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 {
|
||||
this.directMessages.closeConversationView(this.routeConversationId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Store } from '@ngrx/store';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
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 type {
|
||||
Channel,
|
||||
@@ -14,6 +17,7 @@ import type {
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import { CHUNK_SIZE, chunkArray } from '../../../../store/messages/messages.helpers';
|
||||
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
@@ -24,10 +28,13 @@ import {
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
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 {
|
||||
PluginApiAvatarUpdate,
|
||||
PluginApiActionContext,
|
||||
PluginApiActionSource,
|
||||
PluginApiAttachmentImportRequest,
|
||||
PluginApiChannelRequest,
|
||||
PluginApiCustomStreamRequest,
|
||||
PluginApiMessageAsPluginUserRequest,
|
||||
@@ -44,11 +51,13 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginClientApiService {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly capabilities = inject(PluginCapabilityService);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly logger = inject(PluginLoggerService);
|
||||
private readonly messageBus = inject(PluginMessageBusService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly store = inject(Store);
|
||||
private readonly storage = inject(PluginStorageService);
|
||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||
@@ -71,7 +80,11 @@ export class PluginClientApiService {
|
||||
channels: {
|
||||
addAudioChannel: (request) => {
|
||||
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) => {
|
||||
requireCapability('channels.manage');
|
||||
@@ -143,6 +156,15 @@ export class PluginClientApiService {
|
||||
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: {
|
||||
addCustomAudioStream: async (request) => {
|
||||
requireCapability('media.addAudioStream');
|
||||
@@ -190,6 +212,10 @@ export class PluginClientApiService {
|
||||
requireCapability('messages.send');
|
||||
this.receivePluginUserMessage(pluginId, request);
|
||||
},
|
||||
import: async (messages) => {
|
||||
requireCapability('messages.sync');
|
||||
await this.importPluginMessages(pluginId, messages);
|
||||
},
|
||||
setTyping: (isTyping, channelId) => {
|
||||
requireCapability('messages.send');
|
||||
this.setTyping(pluginId, isTyping, channelId);
|
||||
@@ -301,6 +327,58 @@ export class PluginClientApiService {
|
||||
|
||||
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) => {
|
||||
requireCapability('server.manage');
|
||||
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 {
|
||||
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
|
||||
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
|
||||
|
||||
@@ -1107,7 +1107,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
|
||||
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
|
||||
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
|
||||
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')),
|
||||
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
|
||||
scope: readPluginInstallScope(value),
|
||||
@@ -1300,6 +1300,44 @@ function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites human-friendly GitHub URLs so the browser can load the underlying
|
||||
* binary asset. Users typically paste links copied from the GitHub web UI which
|
||||
* point at the rendered HTML preview (`github.com/<owner>/<repo>/blob/...`) or
|
||||
* the raw redirector (`github.com/<owner>/<repo>/raw/...`). Both forms must be
|
||||
* mapped to `raw.githubusercontent.com` for `<img>` tags to work.
|
||||
*/
|
||||
function normalizeImageUrl(rawUrl: string | undefined): string | undefined {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
if (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
const kindIndex = segments.findIndex((segment) => segment === 'blob' || segment === 'raw');
|
||||
|
||||
if (kindIndex < 2 || kindIndex >= segments.length - 1) {
|
||||
return rawUrl;
|
||||
}
|
||||
|
||||
const owner = segments[0];
|
||||
const repo = segments[1];
|
||||
const ref = segments.slice(kindIndex + 1).join('/');
|
||||
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}${url.search}`;
|
||||
}
|
||||
|
||||
function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefined {
|
||||
if (!rawUrl) {
|
||||
return undefined;
|
||||
|
||||
@@ -74,6 +74,11 @@ export interface PluginApiAudioClipRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginApiAttachmentImportRequest {
|
||||
files: File[];
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface PluginApiCustomStreamRequest {
|
||||
label?: string;
|
||||
stream: MediaStream;
|
||||
@@ -195,6 +200,7 @@ export interface PluginApiUiContributionMap {
|
||||
export interface TojuClientPluginApi {
|
||||
readonly channels: {
|
||||
addAudioChannel: (request: PluginApiChannelRequest) => void;
|
||||
addTextChannel: (request: PluginApiChannelRequest) => void;
|
||||
addVideoChannel: (request: PluginApiChannelRequest) => void;
|
||||
list: () => Channel[];
|
||||
remove: (channelId: string) => void;
|
||||
@@ -221,6 +227,9 @@ export interface TojuClientPluginApi {
|
||||
remove: (key: string) => Promise<void>;
|
||||
write: (key: string, value: unknown) => Promise<void>;
|
||||
};
|
||||
readonly attachments: {
|
||||
import: (request: PluginApiAttachmentImportRequest) => Promise<void>;
|
||||
};
|
||||
readonly media: {
|
||||
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||
@@ -235,6 +244,7 @@ export interface TojuClientPluginApi {
|
||||
readCurrent: () => Message[];
|
||||
send: (content: string, channelId?: string) => Message;
|
||||
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
||||
import: (messages: Message[]) => Promise<void>;
|
||||
setTyping: (isTyping: boolean, channelId?: string) => void;
|
||||
subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
|
||||
sync: (messages: Message[]) => void;
|
||||
@@ -261,6 +271,7 @@ export interface TojuClientPluginApi {
|
||||
readonly server: {
|
||||
getCurrent: () => Room | null;
|
||||
registerPluginUser: (request: PluginApiPluginUserRequest) => string;
|
||||
updateIcon: (icon: string) => Promise<void>;
|
||||
updatePermissions: (permissions: Partial<RoomPermissions>) => void;
|
||||
updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fromEvent
|
||||
} from 'rxjs';
|
||||
import { PluginActionMenuComponent } from './plugin-action-menu.component';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
|
||||
const GAP = 10;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
@@ -28,6 +29,7 @@ const POSITIONS: ConnectedPosition[] = [
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginActionMenuService {
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private currentOrigin: HTMLElement | null = null;
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private overlaySubscriptions: Subscription | null = null;
|
||||
@@ -47,9 +49,26 @@ export class PluginActionMenuService {
|
||||
}
|
||||
|
||||
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
|
||||
const isMobile = this.viewport.isMobile();
|
||||
|
||||
this.currentOrigin = rawEl;
|
||||
|
||||
if (isMobile) {
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.left('0')
|
||||
.right('0')
|
||||
.bottom('0');
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
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)
|
||||
@@ -61,6 +80,7 @@ export class PluginActionMenuService {
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop()
|
||||
});
|
||||
}
|
||||
|
||||
this.syncThemeVars();
|
||||
|
||||
@@ -68,6 +88,14 @@ export class PluginActionMenuService {
|
||||
const subscriptions = new Subscription();
|
||||
|
||||
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')
|
||||
.pipe(
|
||||
filter((event) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<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"
|
||||
>
|
||||
<header class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-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 md:flex-1">
|
||||
<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"
|
||||
(click)="close()"
|
||||
>
|
||||
@@ -21,9 +21,10 @@
|
||||
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 md:flex md:flex-shrink-0 md:items-center">
|
||||
<button
|
||||
type="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"
|
||||
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"
|
||||
[disabled]="busyAll()"
|
||||
(click)="activateAll()"
|
||||
>
|
||||
@@ -35,7 +36,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-muted"
|
||||
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
|
||||
@@ -44,15 +45,16 @@
|
||||
/>
|
||||
Open Plugin Store
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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'"
|
||||
(click)="setTab('installed')"
|
||||
>
|
||||
@@ -64,7 +66,7 @@
|
||||
</button>
|
||||
<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'"
|
||||
(click)="setTab('extensions')"
|
||||
>
|
||||
@@ -76,7 +78,7 @@
|
||||
</button>
|
||||
<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'"
|
||||
(click)="setTab('requirements')"
|
||||
>
|
||||
@@ -88,7 +90,7 @@
|
||||
</button>
|
||||
<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'"
|
||||
(click)="setTab('settings')"
|
||||
>
|
||||
@@ -100,7 +102,7 @@
|
||||
</button>
|
||||
<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'"
|
||||
(click)="setTab('docs')"
|
||||
>
|
||||
@@ -112,7 +114,7 @@
|
||||
</button>
|
||||
<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'"
|
||||
(click)="setTab('logs')"
|
||||
>
|
||||
@@ -124,7 +126,7 @@
|
||||
</button>
|
||||
</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()) {
|
||||
@case ('extensions') {
|
||||
<div class="space-y-4">
|
||||
@@ -216,7 +218,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<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)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -224,7 +226,7 @@
|
||||
</button>
|
||||
}
|
||||
</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) {
|
||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }} settings</h3>
|
||||
@if (selectedSettingsPages().length > 0) {
|
||||
@@ -255,7 +257,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<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)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -263,14 +265,14 @@
|
||||
</button>
|
||||
}
|
||||
</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) {
|
||||
<h3 class="text-sm font-semibold">{{ plugin.manifest.title }}</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">{{ plugin.manifest.description }}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@for (doc of selectedDocs(); track doc.label) {
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -292,7 +294,7 @@
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<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)"
|
||||
(click)="selectPlugin(entry.manifest.id)"
|
||||
>
|
||||
@@ -323,7 +325,7 @@
|
||||
<div class="space-y-3">
|
||||
@if (entries().length === 0) {
|
||||
<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"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -337,7 +339,7 @@
|
||||
} @else {
|
||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||
<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-primary]="isSelected(entry)"
|
||||
>
|
||||
@@ -351,17 +353,17 @@
|
||||
<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>
|
||||
</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
|
||||
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)"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
<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)"
|
||||
>
|
||||
<ng-icon
|
||||
@@ -372,7 +374,7 @@
|
||||
</button>
|
||||
<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)"
|
||||
(click)="activate(entry)"
|
||||
>
|
||||
@@ -384,7 +386,7 @@
|
||||
</button>
|
||||
<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"
|
||||
(click)="reload(entry)"
|
||||
>
|
||||
@@ -396,7 +398,7 @@
|
||||
</button>
|
||||
<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"
|
||||
(click)="unload(entry)"
|
||||
>
|
||||
@@ -416,7 +418,7 @@
|
||||
}
|
||||
</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) {
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon
|
||||
@@ -430,14 +432,14 @@
|
||||
} @else {
|
||||
<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)"
|
||||
>
|
||||
Grant all requested
|
||||
</button>
|
||||
<div class="mt-3 space-y-2">
|
||||
@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
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
|
||||
@@ -257,11 +257,13 @@
|
||||
@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)]">
|
||||
<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
|
||||
[src]="plugin.imageUrl"
|
||||
[alt]="plugin.title"
|
||||
(error)="hideBrokenImage($event)"
|
||||
(error)="hideBrokenImage($event, plugin)"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@@ -155,6 +155,7 @@ export class PluginStoreComponent implements OnInit {
|
||||
readonly serverInstallOptional = signal(false);
|
||||
readonly serverInstallError = signal<string | null>(null);
|
||||
readonly serverInstallBusy = signal(false);
|
||||
readonly brokenImageKeys = signal<Set<string>>(new Set());
|
||||
|
||||
private destroyed = false;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -530,12 +531,26 @@ export class PluginStoreComponent implements OnInit {
|
||||
return `${plugin.sourceUrl}:${plugin.id}`;
|
||||
}
|
||||
|
||||
hideBrokenImage(event: Event): void {
|
||||
hideBrokenImage(event: Event, plugin: PluginStoreEntry): void {
|
||||
const image = event.target as HTMLImageElement | null;
|
||||
|
||||
if (image) {
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<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">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
@@ -16,6 +66,7 @@
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -27,12 +78,12 @@
|
||||
name="lucidePlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Create
|
||||
<span>Create</span>
|
||||
</button>
|
||||
|
||||
<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"
|
||||
(click)="openSettings()"
|
||||
>
|
||||
@@ -60,13 +111,51 @@
|
||||
}
|
||||
</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]">
|
||||
<app-user-search-list
|
||||
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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
|
||||
@@ -215,7 +304,7 @@
|
||||
} @else {
|
||||
<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"
|
||||
(click)="joinServer(server)"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideArrowLeft,
|
||||
lucideExternalLink,
|
||||
lucideFileText,
|
||||
lucideSearch,
|
||||
@@ -26,7 +27,8 @@ import {
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
lucideChevronDown,
|
||||
lucideLogIn
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@@ -34,14 +36,15 @@ import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
selectSavedRooms,
|
||||
selectCurrentRoom
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
Room,
|
||||
User,
|
||||
type PluginRequirementSummary
|
||||
} from '../../../../shared-kernel';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { ExternalLinkService, ViewportService } from '../../../../core/platform';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||
@@ -83,6 +86,7 @@ interface JoinPluginConsentDialog {
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideArrowLeft,
|
||||
lucideExternalLink,
|
||||
lucideFileText,
|
||||
lucideSearch,
|
||||
@@ -91,7 +95,8 @@ interface JoinPluginConsentDialog {
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
lucideChevronDown,
|
||||
lucideLogIn
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html'
|
||||
@@ -110,14 +115,22 @@ export class ServerSearchComponent implements OnInit {
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private pluginRequirements = inject(PluginRequirementService);
|
||||
private pluginStore = inject(PluginStoreService);
|
||||
private viewport = inject(ViewportService);
|
||||
private searchSubject = new Subject<string>();
|
||||
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 = '';
|
||||
searchResults = this.store.selectSignal(selectSearchResults);
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
error = this.store.selectSignal(selectRoomsError);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||
@@ -235,6 +248,29 @@ export class ServerSearchComponent implements OnInit {
|
||||
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. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
this.openJoinedRoom(room);
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (!isMobile()) {
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
@@ -74,6 +75,7 @@
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<app-debug-console
|
||||
launcherVariant="compact"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
@@ -59,6 +60,8 @@ import { ThemeNodeDirective } from '../../../../domains/theme';
|
||||
export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
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()) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<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.w-[11rem]]="compact()"
|
||||
[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()"
|
||||
class="flex min-w-0 flex-col items-center justify-center overflow-hidden rounded-xl text-center"
|
||||
[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)]'"
|
||||
>
|
||||
<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)]"
|
||||
@@ -67,16 +62,9 @@
|
||||
@if (connected()) {
|
||||
<span
|
||||
class="absolute rounded-full border-card"
|
||||
[class.bottom-3]="compact()"
|
||||
[class.right-3]="compact()"
|
||||
[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()"
|
||||
[ngClass]="
|
||||
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.bg-emerald-400]="speaking()"
|
||||
[class.bg-muted-foreground]="!speaking()"
|
||||
></span>
|
||||
|
||||
@@ -20,11 +20,11 @@ export class PrivateCallParticipantCardComponent {
|
||||
readonly compact = input(false);
|
||||
|
||||
avatarSize(): string {
|
||||
return this.compact() ? '5rem' : 'clamp(4.25rem, 22vw, 10rem)';
|
||||
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';
|
||||
}
|
||||
|
||||
avatarSizeSm(): string {
|
||||
return this.compact() ? '6rem' : this.avatarSize();
|
||||
return this.compact() ? '6rem' : 'clamp(4.25rem, 22vw, 10rem)';
|
||||
}
|
||||
|
||||
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
|
||||
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'"
|
||||
>
|
||||
<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="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
|
||||
<ng-icon
|
||||
@@ -26,8 +47,22 @@
|
||||
|
||||
@if (session()) {
|
||||
<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
|
||||
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()"
|
||||
(ngModelChange)="inviteUserId.set($event)"
|
||||
aria-label="Add user to call"
|
||||
@@ -39,7 +74,7 @@
|
||||
</select>
|
||||
<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()"
|
||||
(click)="inviteSelectedUser()"
|
||||
aria-label="Add user"
|
||||
@@ -55,8 +90,8 @@
|
||||
</header>
|
||||
|
||||
@if (session()) {
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-4 sm:px-5">
|
||||
<div class="relative min-h-0 flex-1 overflow-hidden rounded-2xl border border-border/80 bg-card/45 shadow-sm">
|
||||
<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">
|
||||
@if (activeShares().length > 0) {
|
||||
@if (focusedShare()) {
|
||||
@if (hasMultipleShares()) {
|
||||
@@ -103,17 +138,18 @@
|
||||
</div>
|
||||
}
|
||||
} @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
|
||||
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"
|
||||
>
|
||||
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||
<app-private-call-participant-card
|
||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
></app-private-call-participant-card>
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -122,14 +158,15 @@
|
||||
@if (activeShares().length > 0) {
|
||||
<div class="shrink-0 pt-4">
|
||||
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
|
||||
@for (user of participantUsers(); track trackUserKey($index, user)) {
|
||||
<app-private-call-participant-card
|
||||
*ngFor="let user of participantUsers(); trackBy: trackUserKey"
|
||||
[user]="user"
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[compact]="true"
|
||||
></app-private-call-participant-card>
|
||||
/>
|
||||
}
|
||||
|
||||
@if (hasMultipleShares()) {
|
||||
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
|
||||
@@ -166,7 +203,7 @@
|
||||
(cameraToggled)="toggleCamera()"
|
||||
(screenShareToggled)="toggleScreenShare()"
|
||||
(leaveRequested)="leave()"
|
||||
></app-private-call-controls>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -191,6 +228,7 @@
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
</ng-template>
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked
|
||||
} from '@angular/core';
|
||||
@@ -17,6 +19,7 @@ import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucidePhone,
|
||||
lucideX,
|
||||
lucideUsers,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
} from '../../domains/screen-share';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||
import { ViewportService } from '../../core/platform';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
import { User } from '../../shared-kernel';
|
||||
@@ -60,9 +64,12 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
|
||||
ScreenShareQualityDialogComponent,
|
||||
VoiceWorkspaceStreamTileComponent
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
host: { class: 'block h-full w-full' },
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePhone,
|
||||
lucideX,
|
||||
lucideUsers,
|
||||
lucideUserPlus
|
||||
})
|
||||
@@ -79,13 +86,18 @@ export class PrivateCallComponent {
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private chatResizing = false;
|
||||
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
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')
|
||||
});
|
||||
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||
readonly participantUsers = computed(() => {
|
||||
const session = this.session();
|
||||
@@ -146,12 +158,10 @@ export class PrivateCallComponent {
|
||||
}
|
||||
|
||||
for (const user of this.participantUsers()) {
|
||||
const peerKey = this.getPeerKeyCandidates(user).find(
|
||||
(candidate) => candidate !== localPeerKey
|
||||
&& (
|
||||
!!this.screenShare.getRemoteScreenShareStream(candidate)
|
||||
|| !!this.voice.getRemoteCameraStream(candidate)
|
||||
)
|
||||
const peerKey =
|
||||
this.getPeerKeyCandidates(user).find(
|
||||
(candidate) =>
|
||||
candidate !== localPeerKey && (!!this.screenShare.getRemoteScreenShareStream(candidate) || !!this.voice.getRemoteCameraStream(candidate))
|
||||
) ?? this.userKey(user);
|
||||
|
||||
if (peerKey === localPeerKey) {
|
||||
@@ -192,9 +202,7 @@ export class PrivateCallComponent {
|
||||
|
||||
return null;
|
||||
});
|
||||
readonly focusedShare = computed(
|
||||
() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null
|
||||
);
|
||||
readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null);
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const focusedShareId = this.focusedShareId();
|
||||
|
||||
@@ -217,14 +225,31 @@ export class PrivateCallComponent {
|
||||
const session = this.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 }));
|
||||
}
|
||||
});
|
||||
|
||||
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(() => {
|
||||
const session = this.session();
|
||||
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');
|
||||
});
|
||||
@@ -240,13 +265,9 @@ export class PrivateCallComponent {
|
||||
this.untrackLocalMic();
|
||||
});
|
||||
|
||||
this.screenShare.onRemoteStream
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
this.screenShare.onRemoteStream.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
this.screenShare.onPeerDisconnected
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
this.screenShare.onPeerDisconnected.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||
@@ -284,9 +305,37 @@ export class PrivateCallComponent {
|
||||
}
|
||||
|
||||
this.calls.leaveCall(session.callId);
|
||||
this.calls.closeMobileCallOverlay();
|
||||
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]);
|
||||
}
|
||||
|
||||
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 {
|
||||
this.voice.toggleMute(!this.isMuted());
|
||||
@@ -378,11 +427,9 @@ export class PrivateCallComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!session.participants[userId]?.joined
|
||||
|| !!(
|
||||
user.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === session.callId
|
||||
&& user.voiceState.serverId === session.callId
|
||||
return (
|
||||
!!session.participants[userId]?.joined ||
|
||||
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,7 +484,8 @@ export class PrivateCallComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
@@ -446,7 +494,8 @@ export class PrivateCallComponent {
|
||||
roomId: session.callId,
|
||||
serverId: session.callId
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private remoteParticipantPeerIds(session: DirectCallSession, currentUserId: string): string[] {
|
||||
|
||||
@@ -1,5 +1,136 @@
|
||||
<div class="flex h-full flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
@if (isMobile()) {
|
||||
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
|
||||
<swiper-container
|
||||
#swiperEl
|
||||
class="block min-h-0 w-full flex-1"
|
||||
slides-per-view="1"
|
||||
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 bg-card">
|
||||
<app-rooms-side-panel
|
||||
panelMode="channels"
|
||||
(textChannelSelected)="setMobilePage('main')"
|
||||
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 bg-background">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setMobilePage('channels')"
|
||||
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
|
||||
name="lucideChevronLeft"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
@if (activeChannel(); as channel) {
|
||||
<p class="flex min-w-0 items-center gap-1 truncate text-sm font-semibold text-foreground">
|
||||
@if (channel.type === 'text') {
|
||||
<ng-icon
|
||||
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>
|
||||
@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>
|
||||
|
||||
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
|
||||
@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>
|
||||
}
|
||||
}
|
||||
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
</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 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()"
|
||||
@@ -66,6 +197,7 @@
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -15,18 +20,44 @@ import {
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft
|
||||
lucideChevronLeft,
|
||||
lucidePhoneCall
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.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 { ViewportService } from '../../../core/platform';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
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({
|
||||
selector: 'app-chat-room',
|
||||
@@ -37,6 +68,7 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
ChatMessagesComponent,
|
||||
VoiceWorkspaceComponent,
|
||||
RoomsSidePanelComponent,
|
||||
ServersRailComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -47,32 +79,154 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft
|
||||
lucideChevronLeft,
|
||||
lucidePhoneCall
|
||||
})
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: './chat-room.component.html'
|
||||
})
|
||||
/**
|
||||
* 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 {
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
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 lastSeenChannelId: string | null = null;
|
||||
private lastSeenRoomId: string | null = null;
|
||||
private swiperListenerAttached: SwiperElement | null = null;
|
||||
showMenu = 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);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
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;
|
||||
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'));
|
||||
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||
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. */
|
||||
toggleAdminPanel() {
|
||||
const room = this.currentRoom();
|
||||
@@ -82,3 +236,4 @@ export class ChatRoomComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
computed,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -138,6 +139,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
readonly showVoiceControls = input(true);
|
||||
readonly textChannelSelected = output<string>();
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
@@ -379,6 +381,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
|
||||
this.voiceWorkspace.showChat();
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
this.textChannelSelected.emit(channelId);
|
||||
}
|
||||
|
||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||
|
||||
@@ -63,10 +63,11 @@
|
||||
</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-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-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()"
|
||||
>
|
||||
@@ -75,6 +76,35 @@
|
||||
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
|
||||
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"
|
||||
title="Rotate to landscape"
|
||||
aria-label="Rotate to landscape"
|
||||
(click)="enterLandscapeFullscreen($event)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRotateCw"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
@@ -92,6 +122,72 @@
|
||||
</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()) {
|
||||
<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">
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideRotateCw,
|
||||
lucideVideo,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||
|
||||
@@ -39,6 +41,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideRotateCw,
|
||||
lucideVideo,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
@@ -51,6 +54,7 @@ import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||
})
|
||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||
@@ -64,6 +68,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||
|
||||
readonly isFullscreen = signal(false);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly showFullscreenHeader = signal(true);
|
||||
readonly volume = signal(100);
|
||||
readonly muted = signal(false);
|
||||
@@ -138,6 +143,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unlockOrientation();
|
||||
this.clearFullscreenHeaderHideTimeout();
|
||||
this.showFullscreenHeader.set(true);
|
||||
}
|
||||
@@ -150,6 +156,8 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
if (tile && document.fullscreenElement === tile) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
this.unlockOrientation();
|
||||
}
|
||||
|
||||
canToggleFullscreen(): boolean {
|
||||
@@ -168,22 +176,38 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await this.toggleFullscreen();
|
||||
}
|
||||
|
||||
async toggleFullscreen(event?: Event): Promise<void> {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (!this.canToggleFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
|
||||
if (!tile || !tile.requestFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement === tile) {
|
||||
if (this.isFullscreen()) {
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
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> {
|
||||
@@ -263,6 +287,41 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
: '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 {
|
||||
this.clearFullscreenHeaderHideTimeout();
|
||||
|
||||
@@ -286,3 +345,12 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||
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
|
||||
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.hidden]="isMobile()"
|
||||
(click)="toggleScreenShare()"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ScreenShareQuality,
|
||||
ScreenShareStartOptions
|
||||
} from '../../../domains/screen-share';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
@@ -90,6 +91,8 @@ export class VoiceWorkspaceComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
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 -->
|
||||
<button
|
||||
appThemeNode="serversRailCreateButton"
|
||||
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"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
class="h-[22px] w-[22px] md:h-5 md:w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (dmRailComponent()) {
|
||||
<ng-container *ngComponentOutlet="dmRailComponent()" />
|
||||
}
|
||||
<app-dm-rail />
|
||||
|
||||
@for (call of directCalls.visibleActiveSessions(); track call.callId + ':' + $index) {
|
||||
<div class="group/call relative flex w-full justify-center">
|
||||
@@ -27,7 +25,7 @@
|
||||
|
||||
<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]="
|
||||
callAvatarUrls(call).length > 0
|
||||
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
|
||||
@@ -61,7 +59,7 @@
|
||||
|
||||
<ng-icon
|
||||
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>
|
||||
</div>
|
||||
@@ -83,7 +81,7 @@
|
||||
<button
|
||||
appThemeNode="serversRailItem"
|
||||
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'"
|
||||
[title]="room.name"
|
||||
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
Type,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
@@ -36,6 +35,7 @@ import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { NotificationsFacade } from '../../../domains/notifications';
|
||||
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 { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { hasRoomBanForUser } from '../../../domains/access-control';
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
ContextMenuComponent,
|
||||
DmRailComponent,
|
||||
LeaveServerDialogComponent,
|
||||
ThemeNodeDirective,
|
||||
UserBarComponent
|
||||
@@ -71,15 +72,16 @@ export class ServersRailComponent {
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private banLookupRequestVersion = 0;
|
||||
private visibleSavedRoomCache: Room[] = [];
|
||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
dmRailComponent = signal<Type<unknown> | null>(null);
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
optimisticSelectedRoomId = signal<string | null>(null);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
@@ -94,9 +96,9 @@ export class ServersRailComponent {
|
||||
isOnDirectMessage = toSignal(
|
||||
this.router.events.pipe(
|
||||
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(
|
||||
this.router.events.pipe(
|
||||
@@ -138,7 +140,7 @@ export class ServersRailComponent {
|
||||
passwordPromptRoom = signal<Room | null>(null);
|
||||
joinPassword = signal('');
|
||||
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(() => {
|
||||
const presence: Record<string, number> = {};
|
||||
const seenByRoom = new Map<string, Set<string>>();
|
||||
@@ -181,10 +183,6 @@ export class ServersRailComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void import('../../../domains/direct-message/feature/dm-rail/dm-rail.component').then((module) => {
|
||||
this.dmRailComponent.set(module.DmRailComponent);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
const currentUser = this.currentUser();
|
||||
@@ -192,6 +190,18 @@ export class ServersRailComponent {
|
||||
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
|
||||
.pipe(
|
||||
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
|
||||
@@ -214,6 +224,8 @@ export class ServersRailComponent {
|
||||
createServer(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
@@ -222,6 +234,7 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
joinSavedRoom(room: Room): void {
|
||||
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
@@ -229,18 +242,20 @@ export class ServersRailComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRoomMarkedBanned(room)) {
|
||||
this.bannedServerName.set(room.name);
|
||||
if (this.isRoomMarkedBanned(targetRoom)) {
|
||||
this.bannedServerName.set(targetRoom.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activateSavedRoom(room);
|
||||
this.savedRoomJoinRequests.next({ room });
|
||||
this.optimisticSelectedRoomId.set(targetRoom.id);
|
||||
this.activateSavedRoom(targetRoom);
|
||||
this.savedRoomJoinRequests.next({ room: targetRoom });
|
||||
}
|
||||
|
||||
openCall(callId: string): void {
|
||||
void this.router.navigate(['/call', callId]);
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
void this.directCalls.openCallView(callId);
|
||||
}
|
||||
|
||||
isSelectedCall(callIndex: number): boolean {
|
||||
@@ -335,6 +350,7 @@ export class ServersRailComponent {
|
||||
);
|
||||
|
||||
if (isCurrentRoom) {
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
@@ -378,9 +394,44 @@ export class ServersRailComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optimisticRoomId = this.optimisticSelectedRoomId();
|
||||
|
||||
if (optimisticRoomId) {
|
||||
return optimisticRoomId === 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> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
@@ -492,6 +543,7 @@ export class ServersRailComponent {
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.closePasswordDialog();
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
this.bannedRoomLookup.update((lookup) => ({
|
||||
...lookup,
|
||||
[room.id]: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop -->
|
||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||
<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-0]="!animating()"
|
||||
(click)="onBackdropClick()"
|
||||
@@ -13,15 +13,14 @@
|
||||
aria-label="Close settings"
|
||||
></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
||||
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||
<div
|
||||
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"
|
||||
style="height: min(720px, 88vh)"
|
||||
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"
|
||||
[class.scale-100]="animating()"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.scale-95]="!animating()"
|
||||
[class.md:scale-95]="!animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
@@ -31,18 +30,32 @@
|
||||
aria-labelledby="settings-modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Side Navigation -->
|
||||
<!-- Side Navigation: persistent on desktop; full-width "menu" page on mobile -->
|
||||
<nav
|
||||
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
|
||||
id="settings-modal-title"
|
||||
class="text-lg font-semibold text-foreground"
|
||||
>
|
||||
Settings
|
||||
</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 class="flex-1 overflow-y-auto py-2">
|
||||
@@ -52,8 +65,8 @@
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
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.bg-secondary]="activePage() === page.id"
|
||||
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 && !isMobile()"
|
||||
[class.text-foreground]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-muted-foreground]="activePage() !== page.id"
|
||||
@@ -92,8 +105,8 @@
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
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.bg-secondary]="activePage() === page.id"
|
||||
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 && !isMobile()"
|
||||
[class.text-foreground]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-muted-foreground]="activePage() !== page.id"
|
||||
@@ -123,14 +136,31 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Content: shown alongside nav on desktop; full-width "detail" page on mobile -->
|
||||
<div
|
||||
class="flex flex-1 flex-col min-w-0"
|
||||
[class.hidden]="isMobile() && mobilePage() !== 'detail'"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
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">
|
||||
@if (isMobile()) {
|
||||
<button
|
||||
(click)="backToMenu()"
|
||||
type="button"
|
||||
aria-label="Back to settings menu"
|
||||
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"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideChevronLeft"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<h3 class="truncate text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
General
|
||||
@@ -179,10 +209,12 @@
|
||||
}
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideBell,
|
||||
lucideChevronLeft,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
@@ -83,6 +85,7 @@ import {
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideBell,
|
||||
lucideChevronLeft,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
@@ -103,9 +106,21 @@ export class SettingsModalComponent {
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private theme = inject(ThemeService);
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
private viewport = inject(ViewportService);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
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');
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
@@ -299,6 +314,11 @@ export class SettingsModalComponent {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -360,6 +380,12 @@ export class SettingsModalComponent {
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -386,6 +412,16 @@ export class SettingsModalComponent {
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
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 electronBridge = inject(ElectronBridgeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private cleanup: (() => void) | null = null;
|
||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
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);
|
||||
|
||||
if (this.electronBridge.isAvailable) {
|
||||
|
||||
@@ -57,7 +57,7 @@ The persisted `rooms` store is a local cache of room metadata. Channel topology
|
||||
|
||||
### 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
|
||||
sequenceDiagram
|
||||
@@ -66,11 +66,11 @@ sequenceDiagram
|
||||
participant BDB as BrowserDatabaseService
|
||||
participant IDB as IndexedDB
|
||||
|
||||
Eff->>DB: getMessages(roomId, 50)
|
||||
DB->>BDB: getMessages(roomId, 50)
|
||||
Eff->>DB: getMessages(roomId, 50, 0, channelId?)
|
||||
DB->>BDB: getMessages(roomId, 50, 0, channelId?)
|
||||
BDB->>IDB: tx.objectStore("messages")<br/>.index("roomId").getAll(roomId)
|
||||
IDB-->>BDB: Message[]
|
||||
Note over BDB: Sort by timestamp, slice, normalise
|
||||
Note over BDB: Optional channel filter, sort, slice, normalise
|
||||
BDB-->>DB: 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 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>(
|
||||
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
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.slice(offset, offset + limit)
|
||||
.map((message) => this.normaliseMessage(message));
|
||||
return this.hydrateMessages(messages);
|
||||
}
|
||||
|
||||
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
|
||||
return allRoomMessages
|
||||
const messages = allRoomMessages
|
||||
.filter((message) => message.timestamp > sinceTimestamp)
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.map((message) => this.normaliseMessage(message));
|
||||
.sort((first, second) => first.timestamp - second.timestamp);
|
||||
|
||||
return this.hydrateMessages(messages);
|
||||
}
|
||||
|
||||
/** Delete a message by its ID. */
|
||||
@@ -112,7 +129,11 @@ export class BrowserDatabaseService {
|
||||
async getMessageById(messageId: string): Promise<Message | null> {
|
||||
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. */
|
||||
@@ -520,6 +541,47 @@ export class BrowserDatabaseService {
|
||||
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 {
|
||||
if (message.content === DELETED_MESSAGE_CONTENT) {
|
||||
return { ...message,
|
||||
|
||||
@@ -49,8 +49,19 @@ export class DatabaseService {
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||
|
||||
/** Retrieve messages for a room with optional pagination. */
|
||||
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
|
||||
/** Retrieve the latest messages for a room or channel with optional pagination.
|
||||
*
|
||||
* 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. */
|
||||
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 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[]> {
|
||||
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });
|
||||
getMessages(
|
||||
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[]> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('peer recovery', () => {
|
||||
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();
|
||||
|
||||
const channel = createDataChannel('closed');
|
||||
@@ -24,29 +24,28 @@ describe('peer recovery', () => {
|
||||
|
||||
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);
|
||||
expect(handlers.removePeer).not.toHaveBeenCalled();
|
||||
expect(handlers.createPeerConnection).not.toHaveBeenCalled();
|
||||
|
||||
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.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', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const staleChannel = createDataChannel('closed');
|
||||
const staleChannel = createDataChannel('closing');
|
||||
const replacementChannel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||
const context = createContext('alice');
|
||||
const handlers = createRecoveryHandlers(context);
|
||||
@@ -90,7 +89,7 @@ describe('peer recovery', () => {
|
||||
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();
|
||||
|
||||
const channel = createDataChannel('closed');
|
||||
@@ -99,11 +98,10 @@ describe('peer recovery', () => {
|
||||
|
||||
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected', false));
|
||||
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.createPeerConnection).not.toHaveBeenCalled();
|
||||
expect(handlers.createPeerConnection).toHaveBeenCalledWith('bob', false);
|
||||
expect(handlers.createAndSendOffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,18 @@ export function scheduleDataChannelRecovery(
|
||||
if (channel.readyState === DATA_CHANNEL_STATE_OPEN)
|
||||
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))
|
||||
return;
|
||||
|
||||
@@ -183,33 +195,40 @@ export function scheduleDataChannelRecovery(
|
||||
reason
|
||||
});
|
||||
|
||||
if (latestPeerData.connection.connectionState === CONNECTION_STATE_CONNECTED) {
|
||||
if (latestPeerData.isInitiator && handlers.replaceDataChannel(peerId, channel)) {
|
||||
logger.info('[data-channel] Replaced control channel without recreating media transport', {
|
||||
repairUnavailableDataChannel(context, peerId, channel, reason, handlers);
|
||||
}, DATA_CHANNEL_RECOVERY_GRACE_MS);
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
state.dataChannelRecoveryTimers.set(peerId, timer);
|
||||
}
|
||||
|
||||
export function schedulePeerDisconnectRecovery(
|
||||
|
||||
@@ -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,3 +1,41 @@
|
||||
<!--
|
||||
Two presentations:
|
||||
- Mobile: rendered through `app-bottom-sheet` so confirmations slide up from the bottom.
|
||||
- Desktop: original centered modal with backdrop.
|
||||
-->
|
||||
@if (isMobile()) {
|
||||
<app-bottom-sheet
|
||||
[title]="title()"
|
||||
[ariaLabel]="title()"
|
||||
(dismissed)="cancelled.emit(undefined)"
|
||||
>
|
||||
<div class="px-4 pb-3 pt-1 text-sm text-muted-foreground">
|
||||
<ng-content />
|
||||
</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"
|
||||
@@ -44,3 +82,4 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
HostListener
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [ThemeNodeDirective],
|
||||
imports: [ThemeNodeDirective, BottomSheetComponent],
|
||||
templateUrl: './confirm-dialog.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
@@ -24,6 +27,8 @@ export class ConfirmDialogComponent {
|
||||
confirmed = output<undefined>();
|
||||
cancelled = output<undefined>();
|
||||
|
||||
readonly isMobile = inject(ViewportService).isMobile;
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.cancelled.emit(undefined);
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!--
|
||||
ContextMenu has two presentations:
|
||||
- On phone-sized viewports the menu opens as a bottom sheet anchored to the bottom of the screen.
|
||||
- On desktop it remains an absolutely-positioned popover at the requested (x, y) coordinates.
|
||||
-->
|
||||
@if (isMobile()) {
|
||||
<app-bottom-sheet
|
||||
[title]="sheetTitle()"
|
||||
[ariaLabel]="sheetTitle() || 'Menu'"
|
||||
(dismissed)="closed.emit(undefined)"
|
||||
>
|
||||
<div class="flex flex-col py-1">
|
||||
<ng-content />
|
||||
</div>
|
||||
</app-bottom-sheet>
|
||||
} @else {
|
||||
<!-- Invisible backdrop that captures clicks outside -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
@@ -21,3 +37,4 @@
|
||||
>
|
||||
<ng-content />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ import {
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
OnInit
|
||||
OnInit,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
standalone: true,
|
||||
imports: [ThemeNodeDirective],
|
||||
imports: [ThemeNodeDirective, BottomSheetComponent],
|
||||
templateUrl: './context-menu.component.html',
|
||||
styleUrl: './context-menu.component.scss'
|
||||
})
|
||||
@@ -24,19 +27,32 @@ export class ContextMenuComponent implements OnInit, AfterViewInit {
|
||||
y = input.required<number>();
|
||||
width = input<string>('w-48');
|
||||
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>();
|
||||
|
||||
@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);
|
||||
clampedY = signal(0);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
|
||||
this.clampedY.set(this.clampY(this.y(), 80));
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.isMobile() || !this.panelRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.panelRef.nativeElement.getBoundingClientRect();
|
||||
|
||||
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
|
||||
} from 'rxjs';
|
||||
import { ProfileCardComponent } from './profile-card.component';
|
||||
import { ProfileCardMobileComponent } from './profile-card-mobile.component';
|
||||
import { PROFILE_AVATAR_EDITOR_OVERLAY_CLASS } from '../../../domains/profile-avatar';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { User } from '../../../shared-kernel';
|
||||
|
||||
export type ProfileCardPlacement = 'above' | 'left' | 'auto';
|
||||
@@ -57,6 +59,7 @@ function positionsFor(placement: ProfileCardPlacement): ConnectedPosition[] {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProfileCardService {
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private currentOrigin: HTMLElement | null = null;
|
||||
private outsideClickSub: Subscription | null = null;
|
||||
@@ -76,9 +79,26 @@ export class ProfileCardService {
|
||||
|
||||
const elementRef = origin instanceof ElementRef ? origin : new ElementRef(origin);
|
||||
const placement = options.placement ?? 'auto';
|
||||
const isMobile = this.viewport.isMobile();
|
||||
|
||||
this.currentOrigin = rawEl;
|
||||
|
||||
if (isMobile) {
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.left('0')
|
||||
.right('0')
|
||||
.bottom('0');
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
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)
|
||||
@@ -90,9 +110,26 @@ export class ProfileCardService {
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop()
|
||||
});
|
||||
}
|
||||
|
||||
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 ref = this.overlayRef.attach(portal);
|
||||
|
||||
|
||||
@@ -16,29 +16,42 @@ import { UserStatus } from '../../../shared-kernel';
|
||||
export class UserAvatarComponent {
|
||||
name = input.required<string>();
|
||||
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>('');
|
||||
status = input<UserStatus | undefined>();
|
||||
showStatusBadge = input(false);
|
||||
|
||||
statusBadgeColor = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'online': return 'bg-green-500';
|
||||
case 'away': return 'bg-yellow-500';
|
||||
case 'busy': return 'bg-red-500';
|
||||
case 'offline': return 'bg-gray-500';
|
||||
case 'disconnected': return 'bg-gray-500';
|
||||
default: return 'bg-gray-500';
|
||||
case 'online':
|
||||
return 'bg-green-500';
|
||||
case 'away':
|
||||
return 'bg-yellow-500';
|
||||
case 'busy':
|
||||
return 'bg-red-500';
|
||||
case 'offline':
|
||||
return 'bg-gray-500';
|
||||
case 'disconnected':
|
||||
return 'bg-gray-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
});
|
||||
|
||||
statusBadgeSizeClass = computed(() => {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 'w-2 h-2';
|
||||
case 'sm': return 'w-3 h-3';
|
||||
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 'xs':
|
||||
return 'w-2 h-2';
|
||||
case 'sm':
|
||||
return 'w-3 h-3';
|
||||
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 {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 'w-7 h-7';
|
||||
case 'sm': return 'w-8 h-8';
|
||||
case 'md': return 'w-10 h-10';
|
||||
case 'lg': return 'w-12 h-12';
|
||||
case 'xl': return 'w-16 h-16';
|
||||
case 'xs':
|
||||
return 'w-7 h-7';
|
||||
case 'sm':
|
||||
return 'w-8 h-8';
|
||||
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 {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 28;
|
||||
case 'sm': return 32;
|
||||
case 'md': return 40;
|
||||
case 'lg': return 48;
|
||||
case 'xl': return 64;
|
||||
case 'xs':
|
||||
return 28;
|
||||
case 'sm':
|
||||
return 32;
|
||||
case 'md':
|
||||
return 40;
|
||||
case 'lg':
|
||||
return 48;
|
||||
case 'xl':
|
||||
return 64;
|
||||
case '2xl':
|
||||
return 128;
|
||||
}
|
||||
}
|
||||
|
||||
textClass(): string {
|
||||
switch (this.size()) {
|
||||
case 'xs': return 'text-xs';
|
||||
case 'sm': return 'text-sm';
|
||||
case 'md': return 'text-base font-semibold';
|
||||
case 'lg': return 'text-lg font-semibold';
|
||||
case 'xl': return 'text-xl font-semibold';
|
||||
case 'xs':
|
||||
return 'text-xs';
|
||||
case 'sm':
|
||||
return 'text-sm';
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Shared reusable UI components barrel.
|
||||
*/
|
||||
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 { ConfirmDialogComponent } from './components/confirm-dialog/confirm-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(sendToPeer).toHaveBeenCalledWith('peer-2', {
|
||||
type: 'chat-sync-full',
|
||||
type: 'chat-sync-batch',
|
||||
roomId: 'room-b',
|
||||
messages: roomBMessages
|
||||
});
|
||||
|
||||
@@ -289,6 +289,12 @@ async function processSyncBatch(
|
||||
attachments: AttachmentFacade
|
||||
): Promise<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) {
|
||||
attachments.rememberMessageRoom(incoming.id, incoming.roomId);
|
||||
@@ -305,6 +311,12 @@ async function processSyncBatch(
|
||||
|
||||
if (changed)
|
||||
toUpsert.push(message);
|
||||
|
||||
processed += 1;
|
||||
|
||||
if (processed % YIELD_EVERY === 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAttachmentMetaMap(event.attachments)) {
|
||||
@@ -603,13 +615,20 @@ function handleSyncRequest(
|
||||
return from(
|
||||
(async () => {
|
||||
const all = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0);
|
||||
const syncFullEvent: ChatEvent = {
|
||||
type: 'chat-sync-full',
|
||||
|
||||
// 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: all
|
||||
messages: chunk
|
||||
};
|
||||
|
||||
webrtc.sendToPeer(fromPeerId, syncFullEvent);
|
||||
webrtc.sendToPeer(fromPeerId, syncBatchEvent);
|
||||
}
|
||||
})()
|
||||
).pipe(mergeMap(() => EMPTY));
|
||||
}
|
||||
|
||||
@@ -111,20 +111,25 @@ export class MessagesSyncEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ room }, currentRoom]) => {
|
||||
const activeRoom = currentRoom || room;
|
||||
switchMap(([{ room }, currentRoom]) => {
|
||||
const requestedRoomId = room.id;
|
||||
|
||||
if (!activeRoom)
|
||||
return timer(75).pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
switchMap(([, latestCurrentRoom]) => {
|
||||
const activeRoom = latestCurrentRoom ?? currentRoom ?? room;
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (!activeRoom || activeRoom.id !== requestedRoomId || peers.length === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return from(
|
||||
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
|
||||
).pipe(
|
||||
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 this.webrtc.getConnectedPeers()) {
|
||||
for (const pid of peers) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-sync-summary',
|
||||
@@ -148,6 +153,8 @@ export class MessagesSyncEffects {
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
@@ -23,6 +23,24 @@ export const MessagesActions = createActionGroup({
|
||||
'Load Messages Success': props<{ messages: Message[] }>(),
|
||||
'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. */
|
||||
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(),
|
||||
'Send Message Success': props<{ message: Message }>(),
|
||||
|
||||
@@ -43,13 +43,16 @@ import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
Message,
|
||||
Reaction
|
||||
Reaction,
|
||||
Room
|
||||
} from '../../shared-kernel';
|
||||
import { hydrateMessages } from './messages.helpers';
|
||||
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
|
||||
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
@@ -65,8 +68,9 @@ export class MessagesEffects {
|
||||
loadMessages$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.loadMessages),
|
||||
switchMap(({ roomId }) =>
|
||||
from(this.db.getMessages(roomId)).pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
switchMap(([{ roomId }, currentRoom]) =>
|
||||
from(this.loadInitialMessages(roomId, currentRoom)).pipe(
|
||||
mergeMap(async (messages) => {
|
||||
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. */
|
||||
sendMessage$ = createEffect(() =>
|
||||
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. */
|
||||
export async function hydrateMessage(
|
||||
msg: Message,
|
||||
db: DatabaseService
|
||||
_db: DatabaseService
|
||||
): Promise<Message> {
|
||||
if (msg.isDeleted)
|
||||
return normaliseDeletedMessage(msg);
|
||||
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
|
||||
return reactions.length > 0 ? { ...msg,
|
||||
reactions } : msg;
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Hydrates an array of messages with their reactions. */
|
||||
export async function hydrateMessages(
|
||||
messages: Message[],
|
||||
db: DatabaseService
|
||||
_db: DatabaseService
|
||||
): 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(
|
||||
msg: Message,
|
||||
db: DatabaseService,
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverride?: number
|
||||
): Promise<InventoryItem> {
|
||||
if (msg.isDeleted) {
|
||||
@@ -63,28 +67,33 @@ export async function buildInventoryItem(
|
||||
};
|
||||
}
|
||||
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
const attachments =
|
||||
attachmentCountOverride === undefined
|
||||
? await db.getAttachmentsForMessage(msg.id)
|
||||
: [];
|
||||
|
||||
return { id: msg.id,
|
||||
const item: InventoryItem = {
|
||||
id: msg.id,
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: reactions.length,
|
||||
ac: attachmentCountOverride ?? attachments.length };
|
||||
rc: msg.reactions?.length ?? 0
|
||||
};
|
||||
|
||||
if (attachmentCountOverride !== undefined) {
|
||||
item.ac = attachmentCountOverride;
|
||||
}
|
||||
|
||||
/** Builds a local map of `{timestamp, reactionCount, attachmentCount}` keyed by message ID. */
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
messages: Message[],
|
||||
db: DatabaseService,
|
||||
_db: DatabaseService,
|
||||
attachmentCountOverrides?: ReadonlyMap<string, number>
|
||||
): Promise<Map<string, { ts: number; rc: number; ac: number }>> {
|
||||
const map = new Map<string, { ts: number; rc: number; ac: number }>();
|
||||
|
||||
await Promise.all(
|
||||
messages.map(async (msg) => {
|
||||
for (const msg of messages) {
|
||||
if (msg.isDeleted) {
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
@@ -92,21 +101,15 @@ export async function buildLocalInventoryMap(
|
||||
ac: 0
|
||||
});
|
||||
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
const attachmentCountOverride = attachmentCountOverrides?.get(msg.id);
|
||||
const attachments =
|
||||
attachmentCountOverride === undefined
|
||||
? await db.getAttachmentsForMessage(msg.id)
|
||||
: [];
|
||||
|
||||
map.set(msg.id, { ts: getMessageTimestamp(msg),
|
||||
rc: reactions.length,
|
||||
ac: attachmentCountOverride ?? attachments.length });
|
||||
})
|
||||
);
|
||||
map.set(msg.id, {
|
||||
ts: getMessageTimestamp(msg),
|
||||
rc: msg.reactions?.length ?? 0,
|
||||
ac: attachmentCountOverrides?.get(msg.id) ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,18 @@ export interface MessagesState extends EntityState<Message> {
|
||||
loading: boolean;
|
||||
/** Whether a peer-to-peer sync cycle is in progress. */
|
||||
syncing: boolean;
|
||||
/** Whether a scroll-up older-page fetch is currently in flight. */
|
||||
loadingOlder: boolean;
|
||||
/** Most recent error message from message operations. */
|
||||
error: string | null;
|
||||
/** ID of the room whose messages are currently loaded. */
|
||||
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>({
|
||||
@@ -27,8 +35,10 @@ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Messa
|
||||
export const initialState: MessagesState = messagesAdapter.getInitialState({
|
||||
loading: false,
|
||||
syncing: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
currentRoomId: null
|
||||
currentRoomId: null,
|
||||
exhaustedConversations: {}
|
||||
});
|
||||
|
||||
export const messagesReducer = createReducer(
|
||||
@@ -41,7 +51,8 @@ export const messagesReducer = createReducer(
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
currentRoomId: roomId
|
||||
currentRoomId: roomId,
|
||||
exhaustedConversations: {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +77,30 @@ export const messagesReducer = createReducer(
|
||||
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
|
||||
on(MessagesActions.sendMessage, (state) => ({
|
||||
...state,
|
||||
@@ -202,7 +237,10 @@ export const messagesReducer = createReducer(
|
||||
|
||||
return messagesAdapter.upsertMany(merged, {
|
||||
...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) =>
|
||||
messagesAdapter.removeAll({
|
||||
...state,
|
||||
currentRoomId: null
|
||||
currentRoomId: null,
|
||||
exhaustedConversations: {}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -36,6 +36,21 @@ export const selectMessagesSyncing = createSelector(
|
||||
(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. */
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectMessagesState,
|
||||
|
||||
@@ -340,11 +340,12 @@ export class RoomMembersSyncEffects {
|
||||
const role = room.hostId === currentUser.id
|
||||
? 'host'
|
||||
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
|
||||
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
|
||||
|
||||
return {
|
||||
...roomMemberFromUser(currentUser, Date.now(), role),
|
||||
...roomMemberFromUser(currentUser, seenAt, role),
|
||||
id: existingMember?.id ?? currentUser.id,
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(),
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
|
||||
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
|
||||
role
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
of,
|
||||
from,
|
||||
EMPTY,
|
||||
merge
|
||||
merge,
|
||||
timer
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
@@ -60,6 +61,8 @@ type BlockedRoomAccessAction =
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
| ReturnType<typeof RoomsActions.joinRoomFailure>;
|
||||
|
||||
const VIEW_SERVER_LOAD_DELAY_MS = 75;
|
||||
|
||||
@Injectable()
|
||||
export class RoomsEffects {
|
||||
private actions$ = inject(Actions);
|
||||
@@ -608,7 +611,12 @@ export class RoomsEffects {
|
||||
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 }));
|
||||
};
|
||||
|
||||
@@ -634,7 +642,9 @@ export class RoomsEffects {
|
||||
onViewServerSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
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()])
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,20 @@ function getDefaultTextChannelId(room: Room): string {
|
||||
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) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const normalizedRoom = enrichRoom(room);
|
||||
@@ -220,27 +234,24 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
// View server - just switch the viewed room, stay connected
|
||||
on(RoomsActions.viewServer, (state) => ({
|
||||
on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
|
||||
if (skipBanCheck) {
|
||||
return {
|
||||
...activateRoomView(state, room, true, false),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||
const enriched = enrichRoom(room);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: getDefaultTextChannelId(enriched)
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false, false)),
|
||||
|
||||
// Update room settings
|
||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||
...state,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob: file:; img-src 'self' data: blob: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
|
||||
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' data: blob: http: https:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob: file:; img-src 'self' data: blob: file: http: https:; frame-src https://www.youtube-nocookie.com https://open.spotify.com https://w.soundcloud.com;"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { register as registerSwiperElements } from 'swiper/element/bundle';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
import mermaid from 'mermaid';
|
||||
@@ -9,6 +10,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Register Swiper custom elements (<swiper-container>, <swiper-slide>) globally.
|
||||
registerSwiperElements();
|
||||
|
||||
// Expose mermaid globally for ngx-remark's MermaidComponent
|
||||
window.mermaid = mermaid;
|
||||
mermaid.initialize({
|
||||
|
||||
@@ -11,6 +11,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Global classes consumed by overlay-driven bottom sheets (profile card, plugin action menu).
|
||||
* The CDK overlay container lives outside the Angular component tree, so styling must be global
|
||||
* rather than component-scoped. Keep this in sync with `shared/components/bottom-sheet`.
|
||||
*/
|
||||
@keyframes metoyou-bottom-sheet-slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.metoyou-bottom-sheet-panel {
|
||||
width: 100% !important;
|
||||
max-width: 100vw;
|
||||
max-height: 85vh;
|
||||
border-top-left-radius: 1rem;
|
||||
border-top-right-radius: 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-bottom: none;
|
||||
background: hsl(var(--card));
|
||||
color: hsl(var(--card-foreground));
|
||||
box-shadow: 0 -20px 50px -10px rgb(0 0 0 / 50%);
|
||||
overflow: hidden;
|
||||
animation: metoyou-bottom-sheet-slide-up 220ms ease-out;
|
||||
}
|
||||
|
||||
.metoyou-bottom-sheet-panel > * {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sheet-mode overrides: when the profile card or plugin action menu render inside the bottom
|
||||
* sheet panel they should fill the panel rather than keep their popover chrome (fixed width,
|
||||
* shadow, double border).
|
||||
*/
|
||||
.metoyou-bottom-sheet-panel app-profile-card,
|
||||
.metoyou-bottom-sheet-panel app-plugin-action-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metoyou-bottom-sheet-panel app-plugin-action-menu > div {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Flatten the GIF picker chrome when it renders inside the inline `app-bottom-sheet`.
|
||||
* The picker brings its own rounded border + shadow that visually nest inside the sheet frame.
|
||||
*/
|
||||
.bottom-sheet-panel app-klipy-gif-picker > div {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metoyou-bottom-sheet-panel {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
Reference in New Issue
Block a user