chore: enforce lint across codebase and ban "maybe" in identifiers
Remove member-ordering and complexity eslint-disable comments by reordering class members and applying targeted fixes. Add metoyou/no-maybe-in-naming, type-safe WebRTC e2e harness helpers, and resolve remaining lint errors so npm run lint exits cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -111,7 +111,8 @@ A per-install **provision secret** enables silent account creation on newly adde
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
|
| Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret |
|
||||||
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
| Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged |
|
||||||
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) |
|
| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` so same-name accounts stay distinguishable |
|
||||||
|
| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id |
|
||||||
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
| Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth |
|
||||||
|
|
||||||
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { type BrowserContext, type Page } from '@playwright/test';
|
import { type BrowserContext, type Page } from '@playwright/test';
|
||||||
|
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
|
||||||
|
|
||||||
|
type RtcPeerConnectionArgs = ConstructorParameters<typeof RTCPeerConnection>;
|
||||||
|
type AudioContextArgs = ConstructorParameters<typeof AudioContext>;
|
||||||
|
|
||||||
|
interface ScreenShareMediaStream extends MediaStream {
|
||||||
|
__isScreenShare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow {
|
||||||
|
return scope as unknown as WebRtcTestHarnessWindow;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
|
||||||
@@ -21,11 +32,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
source?: AudioScheduledSourceNode;
|
source?: AudioScheduledSourceNode;
|
||||||
drawIntervalId?: number;
|
drawIntervalId?: number;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
const harness = webRtcHarnessWindow();
|
||||||
|
|
||||||
(window as any).__rtcConnections = connections;
|
harness.__rtcConnections = connections;
|
||||||
(window as any).__rtcDataChannels = dataChannels;
|
harness.__rtcDataChannels = dataChannels;
|
||||||
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
|
harness.__rtcRemoteTracks = [];
|
||||||
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
|
harness.__rtcSyntheticMediaResources = syntheticMediaResources;
|
||||||
|
|
||||||
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
const OriginalRTCPeerConnection = window.RTCPeerConnection;
|
||||||
const trackDataChannel = (channel: RTCDataChannel) => {
|
const trackDataChannel = (channel: RTCDataChannel) => {
|
||||||
@@ -36,7 +48,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
dataChannels.push(channel);
|
dataChannels.push(channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
|
harness.RTCPeerConnection = function(this: RTCPeerConnection, ...args: RtcPeerConnectionArgs) {
|
||||||
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
|
||||||
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
|
||||||
|
|
||||||
@@ -50,7 +62,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
}) as RTCPeerConnection['createDataChannel'];
|
}) as RTCPeerConnection['createDataChannel'];
|
||||||
|
|
||||||
pc.addEventListener('connectionstatechange', () => {
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
(window as any).__lastRtcState = pc.connectionState;
|
harness.__lastRtcState = pc.connectionState;
|
||||||
});
|
});
|
||||||
|
|
||||||
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
|
||||||
@@ -58,7 +70,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
pc.addEventListener('track', (event: RTCTrackEvent) => {
|
||||||
(window as any).__rtcRemoteTracks.push({
|
harness.__rtcRemoteTracks.push({
|
||||||
kind: event.track.kind,
|
kind: event.track.kind,
|
||||||
id: event.track.id,
|
id: event.track.id,
|
||||||
readyState: event.track.readyState
|
readyState: event.track.readyState
|
||||||
@@ -66,10 +78,10 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
});
|
});
|
||||||
|
|
||||||
return pc;
|
return pc;
|
||||||
} as any;
|
} as typeof RTCPeerConnection;
|
||||||
|
|
||||||
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
|
||||||
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
|
Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection);
|
||||||
|
|
||||||
// Patch getDisplayMedia to return a synthetic screen share stream
|
// Patch getDisplayMedia to return a synthetic screen share stream
|
||||||
// (canvas-based video + 880Hz oscillator audio) so the browser
|
// (canvas-based video + 880Hz oscillator audio) so the browser
|
||||||
@@ -144,7 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
// Tag the stream so tests can identify it
|
// Tag the stream so tests can identify it
|
||||||
(resultStream as any).__isScreenShare = true;
|
(resultStream as ScreenShareMediaStream).__isScreenShare = true;
|
||||||
|
|
||||||
return resultStream;
|
return resultStream;
|
||||||
};
|
};
|
||||||
@@ -169,11 +181,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
|
|||||||
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const OrigAudioContext = window.AudioContext;
|
const OrigAudioContext = window.AudioContext;
|
||||||
|
const audioHarness = webRtcHarnessWindow();
|
||||||
|
|
||||||
(window as any).AudioContext = function(this: AudioContext, ...args: any[]) {
|
audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) {
|
||||||
const ctx: AudioContext = new OrigAudioContext(...args);
|
const ctx: AudioContext = new OrigAudioContext(...args);
|
||||||
// Track all created AudioContexts for test diagnostics
|
// Track all created AudioContexts for test diagnostics
|
||||||
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[];
|
const tracked = audioHarness.__trackedAudioContexts ??= [];
|
||||||
|
|
||||||
tracked.push(ctx);
|
tracked.push(ctx);
|
||||||
|
|
||||||
@@ -189,16 +202,16 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return ctx;
|
return ctx;
|
||||||
} as any;
|
} as typeof AudioContext;
|
||||||
|
|
||||||
(window as any).AudioContext.prototype = OrigAudioContext.prototype;
|
audioHarness.AudioContext.prototype = OrigAudioContext.prototype;
|
||||||
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext);
|
Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => (window as any).__rtcConnections?.some(
|
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false,
|
) ?? false,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -211,7 +224,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
|
|||||||
*/
|
*/
|
||||||
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (window as any).__rtcConnections?.some(
|
() => webRtcHarnessWindow().__rtcConnections?.some(
|
||||||
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
@@ -220,7 +233,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
|
|||||||
/** Returns the number of tracked peer connections in `connected` state. */
|
/** Returns the number of tracked peer connections in `connected` state. */
|
||||||
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
export async function getConnectedPeerCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
() => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -229,7 +242,7 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of peer connections are `connected`. */
|
/** Wait until the expected number of peer connections are `connected`. */
|
||||||
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
(count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
|
||||||
(pc) => pc.connectionState === 'connected'
|
(pc) => pc.connectionState === 'connected'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -240,7 +253,7 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
|
|||||||
/** Returns the number of tracked RTCDataChannels in the open state. */
|
/** Returns the number of tracked RTCDataChannels in the open state. */
|
||||||
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
() => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length ?? 0
|
).length ?? 0
|
||||||
);
|
);
|
||||||
@@ -249,7 +262,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
|
|||||||
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
/** Wait until the expected number of tracked RTCDataChannels are open. */
|
||||||
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
(count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
|
||||||
(channel) => channel.readyState === 'open'
|
(channel) => channel.readyState === 'open'
|
||||||
).length === count,
|
).length === count,
|
||||||
expectedCount,
|
expectedCount,
|
||||||
@@ -260,7 +273,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
|
|||||||
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
/** Close every currently-open RTCDataChannel and return how many were closed. */
|
||||||
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
export async function closeOpenDataChannels(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let closed = 0;
|
let closed = 0;
|
||||||
|
|
||||||
@@ -280,7 +293,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
|
|||||||
/** Dispatch a synthetic data-channel error event on each open channel. */
|
/** Dispatch a synthetic data-channel error event on each open channel. */
|
||||||
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
|
||||||
|
|
||||||
let dispatched = 0;
|
let dispatched = 0;
|
||||||
|
|
||||||
@@ -341,7 +354,7 @@ interface PerPeerAudioStat {
|
|||||||
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
/** Get per-peer audio stats for every tracked RTCPeerConnection. */
|
||||||
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length) {
|
if (!connections?.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -358,7 +371,7 @@ export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat
|
|||||||
try {
|
try {
|
||||||
const stats = await pc.getStats();
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||||
@@ -459,7 +472,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -473,8 +486,8 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
|
const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
|
||||||
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
(webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -492,7 +505,7 @@ export async function getAudioStats(page: Page): Promise<{
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
if (report.type === 'outbound-rtp' && kind === 'audio') {
|
||||||
@@ -583,7 +596,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis
|
|||||||
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -600,7 +613,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'audio')
|
if (report.type === 'outbound-rtp' && kind === 'audio')
|
||||||
@@ -692,7 +705,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
inbound: { bytesReceived: number; packetsReceived: number } | null;
|
||||||
}> {
|
}> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return { outbound: null, inbound: null };
|
return { outbound: null, inbound: null };
|
||||||
@@ -706,8 +719,8 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
hasInbound: boolean;
|
hasInbound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
|
const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
|
||||||
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
(webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
|
||||||
|
|
||||||
for (let idx = 0; idx < connections.length; idx++) {
|
for (let idx = 0; idx < connections.length; idx++) {
|
||||||
let stats: RTCStatsReport;
|
let stats: RTCStatsReport;
|
||||||
@@ -725,7 +738,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video') {
|
if (report.type === 'outbound-rtp' && kind === 'video') {
|
||||||
@@ -791,7 +804,7 @@ export async function getVideoStats(page: Page): Promise<{
|
|||||||
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
async () => {
|
async () => {
|
||||||
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!connections?.length)
|
if (!connections?.length)
|
||||||
return false;
|
return false;
|
||||||
@@ -808,7 +821,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
|
|||||||
let hasOut = false;
|
let hasOut = false;
|
||||||
let hasIn = false;
|
let hasIn = false;
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
const kind = report.kind ?? report.mediaType;
|
const kind = report.kind ?? report.mediaType;
|
||||||
|
|
||||||
if (report.type === 'outbound-rtp' && kind === 'video')
|
if (report.type === 'outbound-rtp' && kind === 'video')
|
||||||
@@ -959,7 +972,7 @@ export async function waitForInboundVideoFlow(
|
|||||||
*/
|
*/
|
||||||
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
|
const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
|
||||||
|
|
||||||
if (!conns?.length)
|
if (!conns?.length)
|
||||||
return 'No connections tracked';
|
return 'No connections tracked';
|
||||||
@@ -984,7 +997,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
|||||||
try {
|
try {
|
||||||
const stats = await pc.getStats();
|
const stats = await pc.getStats();
|
||||||
|
|
||||||
stats.forEach((report: any) => {
|
stats.forEach((report: RTCStats) => {
|
||||||
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -994,7 +1007,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
|
|||||||
|
|
||||||
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
lines.push(` getStats() failed: ${err?.message ?? err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
e2e/helpers/webrtc-test-window.types.ts
Normal file
28
e2e/helpers/webrtc-test-window.types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface RtcRemoteTrackSnapshot {
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
readyState: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RtcSyntheticMediaResource {
|
||||||
|
audioCtx: AudioContext;
|
||||||
|
source?: AudioScheduledSourceNode;
|
||||||
|
drawIntervalId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebRtcTestHarnessWindow extends Window {
|
||||||
|
__rtcConnections: RTCPeerConnection[];
|
||||||
|
__rtcDataChannels: RTCDataChannel[];
|
||||||
|
__rtcRemoteTracks: RtcRemoteTrackSnapshot[];
|
||||||
|
__rtcSyntheticMediaResources: RtcSyntheticMediaResource[];
|
||||||
|
__trackedAudioContexts?: AudioContext[];
|
||||||
|
__rtcStatsHWM?: Record<number, Record<string, number | boolean>>;
|
||||||
|
__rtcVideoStatsHWM?: Record<number, Record<string, number | boolean>>;
|
||||||
|
__lastRtcState?: RTCPeerConnectionState;
|
||||||
|
RTCPeerConnection: typeof RTCPeerConnection;
|
||||||
|
AudioContext: typeof AudioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebRtcTestHarnessWindow(): WebRtcTestHarnessWindow {
|
||||||
|
return window as unknown as WebRtcTestHarnessWindow;
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ module.exports = tseslint.config(
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
'metoyou/no-unicode-symbols': 'error',
|
'metoyou/no-unicode-symbols': 'error',
|
||||||
|
'metoyou/no-maybe-in-naming': 'error',
|
||||||
'@typescript-eslint/no-extraneous-class': 'off',
|
'@typescript-eslint/no-extraneous-class': 'off',
|
||||||
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
|
||||||
'@angular-eslint/directive-class-suffix': 'error',
|
'@angular-eslint/directive-class-suffix': 'error',
|
||||||
@@ -177,6 +178,7 @@ module.exports = tseslint.config(
|
|||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
rules: {
|
||||||
'metoyou/no-unicode-symbols': 'error',
|
'metoyou/no-unicode-symbols': 'error',
|
||||||
|
'metoyou/no-maybe-in-naming': 'error',
|
||||||
// Angular template best practices
|
// Angular template best practices
|
||||||
'@angular-eslint/template/button-has-type': 'warn',
|
'@angular-eslint/template/button-has-type': 'warn',
|
||||||
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ describe('broadcastToServer', () => {
|
|||||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||||
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||||
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
const connA1Passive = connectedUsers.get('conn-a1')?.ws as WebSocket & { sentMessages: string[] } | undefined;
|
||||||
|
|
||||||
|
expect(connA1Passive?.sentMessages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||||
|
|||||||
@@ -94,12 +94,13 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('relays voice_state to other connections for the same user', async () => {
|
it('relays voice_state to other connections for the same user', async () => {
|
||||||
const sender = createConnectedUser('conn-a1', {
|
createConnectedUser('conn-a1', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
serverIds: new Set(['server-1']),
|
serverIds: new Set(['server-1']),
|
||||||
clientInstanceId: 'device-a'
|
clientInstanceId: 'device-a'
|
||||||
});
|
});
|
||||||
|
|
||||||
const passive = createConnectedUser('conn-a2', {
|
const passive = createConnectedUser('conn-a2', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
@@ -129,7 +130,7 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||||
const sender = createConnectedUser('conn-sender', {
|
createConnectedUser('conn-sender', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-2',
|
oderId: 'user-2',
|
||||||
serverIds: new Set(['server-1'])
|
serverIds: new Set(['server-1'])
|
||||||
@@ -228,6 +229,7 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
serverIds: new Set(['server-1']),
|
serverIds: new Set(['server-1']),
|
||||||
clientInstanceId: 'device-a'
|
clientInstanceId: 'device-a'
|
||||||
});
|
});
|
||||||
|
|
||||||
const receiver = createConnectedUser('conn-a2', {
|
const receiver = createConnectedUser('conn-a2', {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
oderId: 'user-1',
|
oderId: 'user-1',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,12 +33,6 @@ const DEFAULT_VOLUME = 0.2;
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class NotificationAudioService {
|
export class NotificationAudioService {
|
||||||
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
|
||||||
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
|
||||||
|
|
||||||
private readonly sources = new Map<AppSound, string>();
|
|
||||||
|
|
||||||
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
|
|
||||||
|
|
||||||
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
/** Reactive notification volume (0 - 1), persisted to localStorage. */
|
||||||
readonly notificationVolume = signal(this.loadVolume());
|
readonly notificationVolume = signal(this.loadVolume());
|
||||||
@@ -47,45 +40,17 @@ export class NotificationAudioService {
|
|||||||
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
/** When true, all sound playback is suppressed (Do Not Disturb). */
|
||||||
readonly dndMuted = signal(false);
|
readonly dndMuted = signal(false);
|
||||||
|
|
||||||
|
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
|
||||||
|
private readonly cache = new Map<AppSound, HTMLAudioElement>();
|
||||||
|
|
||||||
|
private readonly sources = new Map<AppSound, string>();
|
||||||
|
|
||||||
|
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.preload();
|
this.preload();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
|
||||||
private preload(): void {
|
|
||||||
for (const sound of Object.values(AppSound)) {
|
|
||||||
const src = this.resolveAudioUrl(sound);
|
|
||||||
const audio = new Audio();
|
|
||||||
|
|
||||||
audio.preload = 'auto';
|
|
||||||
audio.src = src;
|
|
||||||
audio.load();
|
|
||||||
|
|
||||||
this.sources.set(sound, src);
|
|
||||||
this.cache.set(sound, audio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveAudioUrl(sound: AppSound): string {
|
|
||||||
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read persisted volume from localStorage, falling back to the default. */
|
|
||||||
private loadVolume(): number {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME);
|
|
||||||
|
|
||||||
if (raw !== null) {
|
|
||||||
const parsed = parseFloat(raw);
|
|
||||||
|
|
||||||
if (!isNaN(parsed))
|
|
||||||
return Math.max(0, Math.min(1, parsed));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return DEFAULT_VOLUME;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the notification volume and persist it.
|
* Update the notification volume and persist it.
|
||||||
*
|
*
|
||||||
@@ -178,4 +143,40 @@ export class NotificationAudioService {
|
|||||||
audio.remove();
|
audio.remove();
|
||||||
this.activeLoops.delete(sound);
|
this.activeLoops.delete(sound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
|
||||||
|
private preload(): void {
|
||||||
|
for (const sound of Object.values(AppSound)) {
|
||||||
|
const src = this.resolveAudioUrl(sound);
|
||||||
|
const audio = new Audio();
|
||||||
|
|
||||||
|
audio.preload = 'auto';
|
||||||
|
audio.src = src;
|
||||||
|
audio.load();
|
||||||
|
|
||||||
|
this.sources.set(sound, src);
|
||||||
|
this.cache.set(sound, audio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAudioUrl(sound: AppSound): string {
|
||||||
|
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read persisted volume from localStorage, falling back to the default. */
|
||||||
|
private loadVolume(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME);
|
||||||
|
|
||||||
|
if (raw !== null) {
|
||||||
|
const parsed = parseFloat(raw);
|
||||||
|
|
||||||
|
if (!isNaN(parsed))
|
||||||
|
return Math.max(0, Math.min(1, parsed));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return DEFAULT_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
signal,
|
signal,
|
||||||
@@ -18,6 +17,10 @@ const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TimeSyncService {
|
export class TimeSyncService {
|
||||||
|
|
||||||
|
/** Reactive read-only offset (milliseconds). */
|
||||||
|
readonly offset = computed(() => this._offset());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal offset signal:
|
* Internal offset signal:
|
||||||
* `serverTime = Date.now() + offset`.
|
* `serverTime = Date.now() + offset`.
|
||||||
@@ -27,9 +30,6 @@ export class TimeSyncService {
|
|||||||
/** Epoch timestamp of the most recent successful sync. */
|
/** Epoch timestamp of the most recent successful sync. */
|
||||||
private lastSyncTimestamp = 0;
|
private lastSyncTimestamp = 0;
|
||||||
|
|
||||||
/** Reactive read-only offset (milliseconds). */
|
|
||||||
readonly offset = computed(() => this._offset());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a server-adjusted "now" timestamp.
|
* Return a server-adjusted "now" timestamp.
|
||||||
*
|
*
|
||||||
@@ -97,4 +97,5 @@ export class TimeSyncService {
|
|||||||
// Sync failure is non-fatal; retain the previous offset.
|
// Sync failure is non-fatal; retain the previous offset.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,17 +27,21 @@ interface StoredFileRecord {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BrowserAttachmentFileStore implements AttachmentFileStore {
|
export class BrowserAttachmentFileStore implements AttachmentFileStore {
|
||||||
readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES;
|
readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES;
|
||||||
readonly supportsStreamingToDisk = false;
|
|
||||||
readonly supportsChunkedReads = true;
|
|
||||||
readonly providesInlineObjectUrl = false;
|
|
||||||
|
|
||||||
private database: IDBDatabase | null = null;
|
readonly supportsStreamingToDisk = false;
|
||||||
private activeDatabaseName: string | null = null;
|
|
||||||
|
readonly supportsChunkedReads = true;
|
||||||
|
|
||||||
|
readonly providesInlineObjectUrl = false;
|
||||||
|
|
||||||
get isAvailable(): boolean {
|
get isAvailable(): boolean {
|
||||||
return typeof indexedDB !== 'undefined';
|
return typeof indexedDB !== 'undefined';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private database: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
private activeDatabaseName: string | null = null;
|
||||||
|
|
||||||
async getAppDataPath(): Promise<string | null> {
|
async getAppDataPath(): Promise<string | null> {
|
||||||
return this.isAvailable ? BROWSER_APP_DATA_ROOT : null;
|
return this.isAvailable ? BROWSER_APP_DATA_ROOT : null;
|
||||||
}
|
}
|
||||||
@@ -225,4 +229,5 @@ export class BrowserAttachmentFileStore implements AttachmentFileStore {
|
|||||||
transaction.onabort = () => reject(transaction.error);
|
transaction.onabort = () => reject(transaction.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,19 @@ const CAPACITOR_APP_DATA_ROOT = 'metoyou';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CapacitorAttachmentFileStore implements AttachmentFileStore {
|
export class CapacitorAttachmentFileStore implements AttachmentFileStore {
|
||||||
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
|
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
|
||||||
readonly supportsStreamingToDisk = true;
|
|
||||||
readonly supportsChunkedReads = false;
|
|
||||||
readonly providesInlineObjectUrl = true;
|
|
||||||
|
|
||||||
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
|
readonly supportsStreamingToDisk = true;
|
||||||
|
|
||||||
|
readonly supportsChunkedReads = false;
|
||||||
|
|
||||||
|
readonly providesInlineObjectUrl = true;
|
||||||
|
|
||||||
get isAvailable(): boolean {
|
get isAvailable(): boolean {
|
||||||
return isCapacitorNativeRuntime();
|
return isCapacitorNativeRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
|
||||||
|
|
||||||
async getAppDataPath(): Promise<string | null> {
|
async getAppDataPath(): Promise<string | null> {
|
||||||
return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null;
|
return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null;
|
||||||
}
|
}
|
||||||
@@ -200,4 +203,5 @@ export class CapacitorAttachmentFileStore implements AttachmentFileStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import type { AttachmentFileStore } from './attachment-file-store';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
||||||
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
|
readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
|
||||||
readonly supportsStreamingToDisk = true;
|
|
||||||
readonly supportsChunkedReads = true;
|
|
||||||
readonly providesInlineObjectUrl = false;
|
|
||||||
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
readonly supportsStreamingToDisk = true;
|
||||||
|
|
||||||
|
readonly supportsChunkedReads = true;
|
||||||
|
|
||||||
|
readonly providesInlineObjectUrl = false;
|
||||||
|
|
||||||
get isAvailable(): boolean {
|
get isAvailable(): boolean {
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
@@ -18,6 +19,8 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
|||||||
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
|
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
async getAppDataPath(): Promise<string | null> {
|
async getAppDataPath(): Promise<string | null> {
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
@@ -169,4 +172,5 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, tap } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
@@ -18,47 +17,17 @@ import { MessageSigningService } from './message-signing.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthenticationService {
|
export class AuthenticationService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||||
|
|
||||||
private readonly messageSigning = inject(MessageSigningService);
|
private readonly messageSigning = inject(MessageSigningService);
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the API base URL for the given server.
|
|
||||||
*
|
|
||||||
* @param serverId - Optional server ID to look up. When omitted the
|
|
||||||
* currently active endpoint is used.
|
|
||||||
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
|
||||||
*/
|
|
||||||
private resolveServerUrl(serverId?: string): string {
|
|
||||||
return this.endpointFor(serverId).replace(/\/api$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
|
||||||
const serverUrl = this.resolveServerUrl(serverId);
|
|
||||||
|
|
||||||
this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveServerUrlFor(serverId?: string): string {
|
resolveServerUrlFor(serverId?: string): string {
|
||||||
return this.resolveServerUrl(serverId);
|
return this.resolveServerUrl(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private endpointFor(serverId?: string): string {
|
|
||||||
let endpoint: ServerEndpoint | undefined;
|
|
||||||
|
|
||||||
if (serverId) {
|
|
||||||
endpoint = this.serverDirectory.servers().find(
|
|
||||||
(server) => server.id === serverId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
|
||||||
|
|
||||||
return activeEndpoint
|
|
||||||
? `${activeEndpoint.url}/api`
|
|
||||||
: this.serverDirectory.getApiBaseUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user account on the target server.
|
* Register a new user account on the target server.
|
||||||
*
|
*
|
||||||
@@ -115,4 +84,38 @@ export class AuthenticationService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the API base URL for the given server.
|
||||||
|
*
|
||||||
|
* @param serverId - Optional server ID to look up. When omitted the
|
||||||
|
* currently active endpoint is used.
|
||||||
|
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
||||||
|
*/
|
||||||
|
private resolveServerUrl(serverId?: string): string {
|
||||||
|
return this.endpointFor(serverId).replace(/\/api$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistSessionToken(serverId: string | undefined, response: LoginResponse): void {
|
||||||
|
const serverUrl = this.resolveServerUrl(serverId);
|
||||||
|
|
||||||
|
this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private endpointFor(serverId?: string): string {
|
||||||
|
let endpoint: ServerEndpoint | undefined;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
endpoint = this.serverDirectory.servers().find(
|
||||||
|
(server) => server.id === serverId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
||||||
|
|
||||||
|
return activeEndpoint
|
||||||
|
? `${activeEndpoint.url}/api`
|
||||||
|
: this.serverDirectory.getApiBaseUrl();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,20 @@ export class MessageSigningService {
|
|||||||
const stored = this.readStoredKeyPair();
|
const stored = this.readStoredKeyPair();
|
||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const [publicKey, privateKey] = await Promise.all([crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']), crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])]);
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
stored.publicKeyJwk,
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
true,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
stored.privateKeyJwk,
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
return { publicKey, privateKey };
|
return { publicKey, privateKey };
|
||||||
}
|
}
|
||||||
@@ -111,7 +124,8 @@ export class MessageSigningService {
|
|||||||
true,
|
true,
|
||||||
['sign', 'verify']
|
['sign', 'verify']
|
||||||
);
|
);
|
||||||
const [publicKeyJwk, privateKeyJwk] = await Promise.all([crypto.subtle.exportKey('jwk', generated.publicKey), crypto.subtle.exportKey('jwk', generated.privateKey)]);
|
const publicKeyJwk = await crypto.subtle.exportKey('jwk', generated.publicKey);
|
||||||
|
const privateKeyJwk = await crypto.subtle.exportKey('jwk', generated.privateKey);
|
||||||
|
|
||||||
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
getMessageRevision,
|
getMessageRevision,
|
||||||
resolveMessageRevision
|
resolveMessageRevision
|
||||||
} from './message-integrity.rules';
|
} from './message-integrity.rules';
|
||||||
import { getMessageTimestamp } from './message.rules';
|
|
||||||
|
|
||||||
export interface BuildMessageRevisionInput {
|
export interface BuildMessageRevisionInput {
|
||||||
message: Message;
|
message: Message;
|
||||||
type: MessageRevisionType;
|
type: MessageRevisionType;
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { findMissingIds } from './message-sync.rules';
|
|||||||
|
|
||||||
describe('message-sync.rules', () => {
|
describe('message-sync.rules', () => {
|
||||||
it('requests ids with newer revision or mismatched head hash', () => {
|
it('requests ids with newer revision or mismatched head hash', () => {
|
||||||
const localMap = new Map([['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }], ['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]]);
|
const localMap = new Map<string, { ts: number; rc: number; ac: number; revision: number; headHash: string }>();
|
||||||
|
|
||||||
|
localMap.set('m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' });
|
||||||
|
localMap.set('m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' });
|
||||||
const missing = findMissingIds([
|
const missing = findMissingIds([
|
||||||
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
|
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
|
||||||
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
|
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ import type { RoomSignalSourceInput } from '../../server-directory';
|
|||||||
standalone: true
|
standalone: true
|
||||||
})
|
})
|
||||||
export class ChatImageProxyFallbackDirective {
|
export class ChatImageProxyFallbackDirective {
|
||||||
|
|
||||||
|
@HostBinding('src')
|
||||||
|
get src(): string {
|
||||||
|
return this.renderedSource();
|
||||||
|
}
|
||||||
|
|
||||||
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
||||||
|
|
||||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
private readonly renderedSource = signal('');
|
private readonly renderedSource = signal('');
|
||||||
|
|
||||||
private hasAppliedProxyFallback = false;
|
private hasAppliedProxyFallback = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -29,11 +38,6 @@ export class ChatImageProxyFallbackDirective {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostBinding('src')
|
|
||||||
get src(): string {
|
|
||||||
return this.renderedSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('error')
|
@HostListener('error')
|
||||||
handleError(): void {
|
handleError(): void {
|
||||||
if (this.hasAppliedProxyFallback) {
|
if (this.hasAppliedProxyFallback) {
|
||||||
@@ -49,4 +53,5 @@ export class ChatImageProxyFallbackDirective {
|
|||||||
this.hasAppliedProxyFallback = true;
|
this.hasAppliedProxyFallback = true;
|
||||||
this.renderedSource.set(proxyUrl);
|
this.renderedSource.set(proxyUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
@@ -67,42 +66,64 @@ import {
|
|||||||
})
|
})
|
||||||
export class ChatMessagesComponent {
|
export class ChatMessagesComponent {
|
||||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||||
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
|
|
||||||
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
|
||||||
private readonly store = inject(Store);
|
|
||||||
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 isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||||
|
|
||||||
readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages);
|
readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages);
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
||||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
|
|
||||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
|
|
||||||
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
|
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
|
||||||
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||||
|
|
||||||
readonly conversationExhausted = toSignal(
|
readonly conversationExhausted = toSignal(
|
||||||
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
|
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
|
||||||
{ initialValue: false }
|
{ initialValue: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||||
|
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
|
|
||||||
readonly klipyGifPickerAnchorRight = signal(16);
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
|
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
|
|
||||||
readonly showKlipyGifPicker = signal(false);
|
readonly showKlipyGifPicker = signal(false);
|
||||||
|
|
||||||
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
||||||
|
|
||||||
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
||||||
|
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
|
|
||||||
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
void this.klipy.refreshAvailability(this.currentRoom());
|
void this.klipy.refreshAvailability(this.currentRoom());
|
||||||
@@ -262,36 +283,6 @@ export class ChatMessagesComponent {
|
|||||||
this.composer?.handleKlipyGifSelected(gif);
|
this.composer?.handleKlipyGifSelected(gif);
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncKlipyGifPickerAnchor(): void {
|
|
||||||
const triggerRect = this.composer?.getKlipyTriggerRect();
|
|
||||||
|
|
||||||
if (!triggerRect) {
|
|
||||||
this.klipyGifPickerAnchorRight.set(16);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
|
|
||||||
const preferredRight = viewportWidth - triggerRect.right;
|
|
||||||
const minRight = 16;
|
|
||||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
|
||||||
|
|
||||||
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
|
||||||
if (viewportWidth >= 1280)
|
|
||||||
return 52 * 16;
|
|
||||||
|
|
||||||
if (viewportWidth >= 768)
|
|
||||||
return 42 * 16;
|
|
||||||
|
|
||||||
if (viewportWidth >= 640)
|
|
||||||
return 34 * 16;
|
|
||||||
|
|
||||||
return Math.max(0, viewportWidth - 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
openLightbox(event: ChatMessageImageLightboxEvent): void {
|
openLightbox(event: ChatMessageImageLightboxEvent): void {
|
||||||
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
|
||||||
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
|
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id);
|
||||||
@@ -411,6 +402,36 @@ export class ChatMessagesComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncKlipyGifPickerAnchor(): void {
|
||||||
|
const triggerRect = this.composer?.getKlipyTriggerRect();
|
||||||
|
|
||||||
|
if (!triggerRect) {
|
||||||
|
this.klipyGifPickerAnchorRight.set(16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
|
||||||
|
const preferredRight = viewportWidth - triggerRect.right;
|
||||||
|
const minRight = 16;
|
||||||
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||||
|
|
||||||
|
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||||
|
if (viewportWidth >= 1280)
|
||||||
|
return 52 * 16;
|
||||||
|
|
||||||
|
if (viewportWidth >= 768)
|
||||||
|
return 42 * 16;
|
||||||
|
|
||||||
|
if (viewportWidth >= 640)
|
||||||
|
return 34 * 16;
|
||||||
|
|
||||||
|
return Math.max(0, viewportWidth - 32);
|
||||||
|
}
|
||||||
|
|
||||||
private isOwnMessage(message: Message): boolean {
|
private isOwnMessage(message: Message): boolean {
|
||||||
return message.senderId === this.currentUser()?.id;
|
return message.senderId === this.currentUser()?.id;
|
||||||
}
|
}
|
||||||
@@ -507,4 +528,5 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined);
|
this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
@@ -108,38 +107,43 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|||||||
})
|
})
|
||||||
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||||
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||||
|
|
||||||
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
||||||
|
|
||||||
readonly replyTo = input<Message | null>(null);
|
readonly replyTo = input<Message | null>(null);
|
||||||
|
|
||||||
readonly showKlipyGifPicker = input(false);
|
readonly showKlipyGifPicker = input(false);
|
||||||
|
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
|
|
||||||
readonly klipyEnabled = input(false);
|
readonly klipyEnabled = input(false);
|
||||||
|
|
||||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
|
|
||||||
readonly textareaTestId = input<string | null>(null);
|
readonly textareaTestId = input<string | null>(null);
|
||||||
|
|
||||||
readonly commandSurface = input<SlashCommandSurface>('server');
|
readonly commandSurface = input<SlashCommandSurface>('server');
|
||||||
|
|
||||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||||
|
|
||||||
readonly typingStarted = output();
|
readonly typingStarted = output();
|
||||||
|
|
||||||
readonly replyCleared = output();
|
readonly replyCleared = output();
|
||||||
|
|
||||||
readonly heightChanged = output<number>();
|
readonly heightChanged = output<number>();
|
||||||
|
|
||||||
readonly klipyGifPickerToggleRequested = output();
|
readonly klipyGifPickerToggleRequested = output();
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
|
||||||
private readonly markdown = inject(ChatMarkdownService);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly pluginApi = inject(PluginClientApiService);
|
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||||
|
|
||||||
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
|
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
|
||||||
|
|
||||||
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
|
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
|
||||||
|
|
||||||
readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled()));
|
readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled()));
|
||||||
|
|
||||||
readonly composerTextareaPaddingClass = computed(() =>
|
readonly composerTextareaPaddingClass = computed(() =>
|
||||||
resolveComposerTextareaPaddingClass({
|
resolveComposerTextareaPaddingClass({
|
||||||
isMobileViewport: this.viewport.isMobile(),
|
isMobileViewport: this.viewport.isMobile(),
|
||||||
@@ -147,38 +151,78 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
klipyEnabled: this.klipyEnabled()
|
klipyEnabled: this.klipyEnabled()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly showComposerMediaMenu = signal(false);
|
readonly showComposerMediaMenu = signal(false);
|
||||||
|
|
||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
|
|
||||||
readonly emojiButton = signal('🙂');
|
readonly emojiButton = signal('🙂');
|
||||||
|
|
||||||
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||||
|
|
||||||
readonly slashQuery = signal<string | null>(null);
|
readonly slashQuery = signal<string | null>(null);
|
||||||
|
|
||||||
readonly slashActiveIndex = signal(0);
|
readonly slashActiveIndex = signal(0);
|
||||||
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries(
|
|
||||||
(text) => this.sendBuiltInSlashText(text),
|
|
||||||
(key) => this.appI18n.instant(key)
|
|
||||||
);
|
|
||||||
readonly availableSlashCommands = computed(() =>
|
readonly availableSlashCommands = computed(() =>
|
||||||
selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface())
|
selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface())
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly slashCommandResults = computed(() => {
|
readonly slashCommandResults = computed(() => {
|
||||||
const query = this.slashQuery();
|
const query = this.slashQuery();
|
||||||
|
|
||||||
return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query);
|
return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0);
|
readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0);
|
||||||
|
|
||||||
readonly toolbarVisible = signal(false);
|
readonly toolbarVisible = signal(false);
|
||||||
|
|
||||||
readonly dragActive = signal(false);
|
readonly dragActive = signal(false);
|
||||||
|
|
||||||
readonly inputHovered = signal(false);
|
readonly inputHovered = signal(false);
|
||||||
|
|
||||||
readonly ctrlHeld = signal(false);
|
readonly ctrlHeld = signal(false);
|
||||||
|
|
||||||
readonly textareaExpanded = signal(false);
|
readonly textareaExpanded = signal(false);
|
||||||
|
|
||||||
messageContent = '';
|
messageContent = '';
|
||||||
|
|
||||||
pendingFiles: File[] = [];
|
pendingFiles: File[] = [];
|
||||||
|
|
||||||
inlineCodeToken = '`';
|
inlineCodeToken = '`';
|
||||||
|
|
||||||
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
|
private readonly markdown = inject(ChatMarkdownService);
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly pluginApi = inject(PluginClientApiService);
|
||||||
|
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
|
||||||
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries(
|
||||||
|
(text) => this.sendBuiltInSlashText(text),
|
||||||
|
(key) => this.appI18n.instant(key)
|
||||||
|
);
|
||||||
|
|
||||||
private toolbarHovering = false;
|
private toolbarHovering = false;
|
||||||
|
|
||||||
private dragDepth = 0;
|
private dragDepth = 0;
|
||||||
|
|
||||||
private lastTypingSentAt = 0;
|
private lastTypingSentAt = 0;
|
||||||
|
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
@@ -194,7 +238,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
sendMessage(): void {
|
sendMessage(): void {
|
||||||
const raw = this.messageContent.trim();
|
const raw = this.messageContent.trim();
|
||||||
|
|
||||||
if (this.maybeRunSlashCommand(raw))
|
if (this.runSlashCommandWhenPresent(raw))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||||
@@ -459,68 +503,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.resetComposerAfterCommand();
|
this.resetComposerAfterCommand();
|
||||||
}
|
}
|
||||||
|
|
||||||
private maybeRunSlashCommand(raw: string): boolean {
|
|
||||||
const parsed = parseSlashCommandInput(raw);
|
|
||||||
|
|
||||||
if (!parsed)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const entry = findSlashCommand(this.availableSlashCommands(), parsed.name);
|
|
||||||
|
|
||||||
if (!entry)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
this.executeSlashCommand(entry, parsed.rawArgs);
|
|
||||||
this.resetComposerAfterCommand();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendBuiltInSlashText(text: string): void {
|
|
||||||
this.messageSubmitted.emit({
|
|
||||||
content: text,
|
|
||||||
pendingFiles: []
|
|
||||||
});
|
|
||||||
|
|
||||||
this.replyCleared.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void {
|
|
||||||
const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []);
|
|
||||||
const context = this.pluginApi.createSlashCommandContext({
|
|
||||||
args,
|
|
||||||
command: entry.contribution.name,
|
|
||||||
rawArgs
|
|
||||||
});
|
|
||||||
|
|
||||||
void Promise.resolve().then(() => entry.contribution.run(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetComposerAfterCommand(): void {
|
|
||||||
this.messageContent = '';
|
|
||||||
this.closeSlashCommandMenu();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.autoResizeTextarea();
|
|
||||||
this.messageInputRef?.nativeElement.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private moveSlashActive(delta: number): void {
|
|
||||||
const total = this.slashCommandResults().length;
|
|
||||||
|
|
||||||
if (total === 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.slashActiveIndex.update((current) => (current + delta + total) % total);
|
|
||||||
}
|
|
||||||
|
|
||||||
private activeSlashCommand(): SlashCommandEntry | null {
|
|
||||||
const results = this.slashCommandResults();
|
|
||||||
|
|
||||||
return results[this.slashActiveIndex()] ?? results[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getKlipyTriggerRect(): DOMRect | null {
|
getKlipyTriggerRect(): DOMRect | null {
|
||||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||||
}
|
}
|
||||||
@@ -686,6 +668,68 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private runSlashCommandWhenPresent(raw: string): boolean {
|
||||||
|
const parsed = parseSlashCommandInput(raw);
|
||||||
|
|
||||||
|
if (!parsed)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const entry = findSlashCommand(this.availableSlashCommands(), parsed.name);
|
||||||
|
|
||||||
|
if (!entry)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.executeSlashCommand(entry, parsed.rawArgs);
|
||||||
|
this.resetComposerAfterCommand();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendBuiltInSlashText(text: string): void {
|
||||||
|
this.messageSubmitted.emit({
|
||||||
|
content: text,
|
||||||
|
pendingFiles: []
|
||||||
|
});
|
||||||
|
|
||||||
|
this.replyCleared.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void {
|
||||||
|
const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []);
|
||||||
|
const context = this.pluginApi.createSlashCommandContext({
|
||||||
|
args,
|
||||||
|
command: entry.contribution.name,
|
||||||
|
rawArgs
|
||||||
|
});
|
||||||
|
|
||||||
|
void Promise.resolve().then(() => entry.contribution.run(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetComposerAfterCommand(): void {
|
||||||
|
this.messageContent = '';
|
||||||
|
this.closeSlashCommandMenu();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.autoResizeTextarea();
|
||||||
|
this.messageInputRef?.nativeElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveSlashActive(delta: number): void {
|
||||||
|
const total = this.slashCommandResults().length;
|
||||||
|
|
||||||
|
if (total === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.slashActiveIndex.update((current) => (current + delta + total) % total);
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeSlashCommand(): SlashCommandEntry | null {
|
||||||
|
const results = this.slashCommandResults();
|
||||||
|
|
||||||
|
return results[this.slashActiveIndex()] ?? results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
private getSelection(): { start: number; end: number } {
|
private getSelection(): { start: number; end: number } {
|
||||||
const element = this.messageInputRef?.nativeElement;
|
const element = this.messageInputRef?.nativeElement;
|
||||||
|
|
||||||
@@ -960,4 +1004,5 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.heightChanged.emit(root.offsetHeight);
|
this.heightChanged.emit(root.offsetHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
|
||||||
@let msg = message();
|
@let msg = message();
|
||||||
@let attachmentsList = attachmentViewModels();
|
@let attachmentsList = attachmentViewModels();
|
||||||
@if (isSystemMessage()) {
|
@if (isSystemMessage()) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
@@ -169,56 +168,61 @@ interface MissingPluginEmbedFallback {
|
|||||||
})
|
})
|
||||||
export class ChatMessageItemComponent implements OnDestroy {
|
export class ChatMessageItemComponent implements OnDestroy {
|
||||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||||
|
|
||||||
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
|
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
|
||||||
|
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
|
||||||
private readonly klipy = inject(KlipyService);
|
|
||||||
private readonly pluginRequirements = inject(PluginRequirementStateService);
|
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly platform = inject(PlatformService);
|
|
||||||
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 readonly appI18n = inject(AppI18nService);
|
|
||||||
private mobileSheetOverlayRef: OverlayRef | null = null;
|
|
||||||
private longPressTimer: number | null = null;
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly mobileSheetOpen = signal(false);
|
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>();
|
|
||||||
|
|
||||||
readonly message = input.required<Message>();
|
readonly message = input.required<Message>();
|
||||||
|
|
||||||
readonly repliedMessage = input<Message | undefined>();
|
readonly repliedMessage = input<Message | undefined>();
|
||||||
|
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
|
|
||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
|
|
||||||
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
|
readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
|
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
|
|
||||||
readonly editSaved = output<ChatMessageEditEvent>();
|
readonly editSaved = output<ChatMessageEditEvent>();
|
||||||
|
|
||||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||||
|
|
||||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||||
|
|
||||||
readonly referenceRequested = output<string>();
|
readonly referenceRequested = output<string>();
|
||||||
|
|
||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
|
|
||||||
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
|
|
||||||
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||||
|
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
|
||||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
|
|
||||||
readonly emojiShortcuts = this.customEmoji.shortcutEntries;
|
readonly emojiShortcuts = this.customEmoji.shortcutEntries;
|
||||||
|
|
||||||
readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted');
|
readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted');
|
||||||
|
|
||||||
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
|
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
|
||||||
|
|
||||||
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
|
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
|
||||||
|
|
||||||
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
|
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
|
||||||
|
|
||||||
readonly isSystemMessage = computed(() => this.message().kind === 'system');
|
readonly isSystemMessage = computed(() => this.message().kind === 'system');
|
||||||
|
|
||||||
readonly isEditing = signal(false);
|
readonly isEditing = signal(false);
|
||||||
|
|
||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
|
|
||||||
readonly senderUser = computed<User>(() => {
|
readonly senderUser = computed<User>(() => {
|
||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
const found = this.userLookup().get(msg.senderId);
|
const found = this.userLookup().get(msg.senderId);
|
||||||
@@ -238,26 +242,60 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
|
|
||||||
openSenderProfileCard(event: MouseEvent): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
const el = event.currentTarget as HTMLElement;
|
|
||||||
const user = this.senderUser();
|
|
||||||
const editable = user.id === this.currentUserId();
|
|
||||||
|
|
||||||
this.profileCard.open(el, user, { editable });
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
|
|
||||||
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
|
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly imageAttachments = computed(() =>
|
readonly imageAttachments = computed(() =>
|
||||||
dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment)))
|
dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment)))
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment)));
|
readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment)));
|
||||||
|
|
||||||
readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage));
|
readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage));
|
||||||
|
|
||||||
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length));
|
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length));
|
||||||
|
|
||||||
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
|
|
||||||
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
|
private readonly pluginRequirements = inject(PluginRequirementStateService);
|
||||||
|
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
|
||||||
|
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 readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private mobileSheetOverlayRef: OverlayRef | null = null;
|
||||||
|
|
||||||
|
private longPressTimer: number | null = null;
|
||||||
|
|
||||||
|
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||||
|
|
||||||
|
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly mediaSupportCache = new Map<string, boolean>();
|
||||||
|
|
||||||
private readonly hydrateMessageImages = effect(() => {
|
private readonly hydrateMessageImages = effect(() => {
|
||||||
const messageId = this.message().id;
|
const messageId = this.message().id;
|
||||||
const images = this.imageAttachments();
|
const images = this.imageAttachments();
|
||||||
@@ -282,6 +320,7 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
|
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
private readonly syncAttachmentVersion = effect(() => {
|
private readonly syncAttachmentVersion = effect(() => {
|
||||||
const version = this.attachmentsSvc.updated();
|
const version = this.attachmentsSvc.updated();
|
||||||
|
|
||||||
@@ -292,6 +331,15 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
openSenderProfileCard(event: MouseEvent): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
const el = event.currentTarget as HTMLElement;
|
||||||
|
const user = this.senderUser();
|
||||||
|
const editable = user.id === this.currentUserId();
|
||||||
|
|
||||||
|
this.profileCard.open(el, user, { editable });
|
||||||
|
}
|
||||||
|
|
||||||
openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
|
openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
|
||||||
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
|
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url;
|
||||||
|
|
||||||
@@ -303,43 +351,6 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private findPluginEmbeds(token: PluginEmbedToken | null) {
|
|
||||||
if (!token) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parseEmbedPayload(token.payloadText);
|
|
||||||
|
|
||||||
return this.pluginUi
|
|
||||||
.embedRecords()
|
|
||||||
.filter((record) => record.contribution.embedType === token.embedType)
|
|
||||||
.map((record) => ({
|
|
||||||
...record,
|
|
||||||
render: () => record.contribution.render(payload)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null {
|
|
||||||
const token = this.pluginEmbedToken();
|
|
||||||
|
|
||||||
if (!token || this.pluginEmbeds().length > 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const missingRequirement =
|
|
||||||
this.pluginRequirements
|
|
||||||
.missingRequiredRequirements()
|
|
||||||
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ??
|
|
||||||
this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ??
|
|
||||||
this.pluginRequirements.missingRequiredRequirements()[0];
|
|
||||||
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pluginName,
|
|
||||||
searchTerm: pluginName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
startEdit(): void {
|
startEdit(): void {
|
||||||
this.editContent = this.message().content;
|
this.editContent = this.message().content;
|
||||||
this.isEditing.set(true);
|
this.isEditing.set(true);
|
||||||
@@ -453,53 +464,10 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
this.clearLongPressTimer();
|
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 {
|
closeMobileActions(): void {
|
||||||
this.detachMobileSheet();
|
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 {
|
ngOnDestroy(): void {
|
||||||
this.clearLongPressTimer();
|
this.clearLongPressTimer();
|
||||||
this.detachMobileSheet();
|
this.detachMobileSheet();
|
||||||
@@ -836,6 +804,86 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
this.experimentalPlayerAttachmentId.set(null);
|
this.experimentalPlayerAttachmentId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findPluginEmbeds(token: PluginEmbedToken | null) {
|
||||||
|
if (!token) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseEmbedPayload(token.payloadText);
|
||||||
|
|
||||||
|
return this.pluginUi
|
||||||
|
.embedRecords()
|
||||||
|
.filter((record) => record.contribution.embedType === token.embedType)
|
||||||
|
.map((record) => ({
|
||||||
|
...record,
|
||||||
|
render: () => record.contribution.render(payload)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null {
|
||||||
|
const token = this.pluginEmbedToken();
|
||||||
|
|
||||||
|
if (!token || this.pluginEmbeds().length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingRequirement =
|
||||||
|
this.pluginRequirements
|
||||||
|
.missingRequiredRequirements()
|
||||||
|
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ??
|
||||||
|
this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ??
|
||||||
|
this.pluginRequirements.missingRequiredRequirements()[0];
|
||||||
|
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pluginName,
|
||||||
|
searchTerm: pluginName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||||
const isRawVideo = this.isVideoAttachment(attachment);
|
const isRawVideo = this.isVideoAttachment(attachment);
|
||||||
const isRawAudio = this.isAudioAttachment(attachment);
|
const isRawAudio = this.isAudioAttachment(attachment);
|
||||||
@@ -907,6 +955,7 @@ export class ChatMessageItemComponent implements OnDestroy {
|
|||||||
|
|
||||||
return canPlay;
|
return canPlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
|
function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -73,15 +72,21 @@ const REMARK_PROCESSOR = unified().use(remarkParse)
|
|||||||
templateUrl: './chat-message-markdown.component.html'
|
templateUrl: './chat-message-markdown.component.html'
|
||||||
})
|
})
|
||||||
export class ChatMessageMarkdownComponent {
|
export class ChatMessageMarkdownComponent {
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
|
|
||||||
readonly content = input.required<string>();
|
readonly content = input.required<string>();
|
||||||
|
|
||||||
readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id)));
|
readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id)));
|
||||||
|
|
||||||
readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content()));
|
readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content()));
|
||||||
|
|
||||||
readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content()));
|
readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content()));
|
||||||
|
|
||||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||||
|
|
||||||
readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments;
|
readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments;
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
shouldRenderLargeCustomEmoji(url?: string): boolean {
|
shouldRenderLargeCustomEmoji(url?: string): boolean {
|
||||||
return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji();
|
return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji();
|
||||||
}
|
}
|
||||||
@@ -141,4 +146,5 @@ export class ChatMessageMarkdownComponent {
|
|||||||
|
|
||||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
AfterViewChecked,
|
AfterViewChecked,
|
||||||
@@ -64,37 +63,50 @@ declare global {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||||
|
private static readonly INITIAL_SETTLE_MS = 1500;
|
||||||
|
|
||||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
|
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
|
||||||
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly allMessages = input.required<Message[]>();
|
readonly allMessages = input.required<Message[]>();
|
||||||
|
|
||||||
readonly channelMessages = input.required<Message[]>();
|
readonly channelMessages = input.required<Message[]>();
|
||||||
|
|
||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
|
|
||||||
readonly syncing = input(false);
|
readonly syncing = input(false);
|
||||||
|
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
|
|
||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
|
|
||||||
readonly bottomPadding = input(120);
|
readonly bottomPadding = input(120);
|
||||||
|
|
||||||
readonly conversationKey = input.required<string>();
|
readonly conversationKey = input.required<string>();
|
||||||
|
|
||||||
readonly userLookupOverrides = input<User[]>([]);
|
readonly userLookupOverrides = input<User[]>([]);
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
|
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
|
|
||||||
readonly editSaved = output<ChatMessageEditEvent>();
|
readonly editSaved = output<ChatMessageEditEvent>();
|
||||||
|
|
||||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||||
|
|
||||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||||
|
|
||||||
readonly downloadRequested = output<Attachment>();
|
readonly downloadRequested = output<Attachment>();
|
||||||
|
|
||||||
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
readonly imageOpened = output<ChatMessageImageLightboxEvent>();
|
||||||
|
|
||||||
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
|
||||||
|
|
||||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||||
|
|
||||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emitted when the user scrolls up past the in-store window and the
|
* 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.
|
* component needs the parent to fetch an older page from the DB.
|
||||||
@@ -103,13 +115,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
|
|
||||||
/** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
|
/** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
|
||||||
readonly loadingOlder = input(false);
|
readonly loadingOlder = input(false);
|
||||||
|
|
||||||
/** True once the parent has paginated all the way back to the start of DB history. */
|
/** True once the parent has paginated all the way back to the start of DB history. */
|
||||||
readonly conversationExhausted = input(false);
|
readonly conversationExhausted = input(false);
|
||||||
|
|
||||||
private readonly PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
readonly displayLimit = signal(this.PAGE_SIZE);
|
readonly displayLimit = signal(this.PAGE_SIZE);
|
||||||
|
|
||||||
readonly loadingMore = signal(false);
|
readonly loadingMore = signal(false);
|
||||||
|
|
||||||
readonly showNewMessagesBar = signal(false);
|
readonly showNewMessagesBar = signal(false);
|
||||||
|
|
||||||
readonly messages = computed(() => {
|
readonly messages = computed(() => {
|
||||||
@@ -123,6 +136,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly initialLoading = computed(() => this.loading() && this.messages().length === 0);
|
readonly initialLoading = computed(() => this.loading() && this.messages().length === 0);
|
||||||
|
|
||||||
readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0);
|
readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0);
|
||||||
|
|
||||||
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
||||||
@@ -167,6 +181,18 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return lookup;
|
return lookup;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
|
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly PAGE_SIZE = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* O(1) index of messages by id, built once per `allMessages()` change.
|
* O(1) index of messages by id, built once per `allMessages()` change.
|
||||||
* Used by `findRepliedMessage` so each rendered row doing a reply lookup
|
* Used by `findRepliedMessage` so each rendered row doing a reply lookup
|
||||||
@@ -183,13 +209,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private contentResizeObserver: ResizeObserver | null = null;
|
private contentResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
private observedContent: HTMLElement | null = null;
|
private observedContent: HTMLElement | null = null;
|
||||||
|
|
||||||
private localSendScrollPending = false;
|
private localSendScrollPending = false;
|
||||||
|
|
||||||
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
private isAutoScrolling = false;
|
private isAutoScrolling = false;
|
||||||
|
|
||||||
private lastMessageCount = 0;
|
private lastMessageCount = 0;
|
||||||
|
|
||||||
private initialScrollPending = true;
|
private initialScrollPending = true;
|
||||||
|
|
||||||
private prismHighlightScheduled = false;
|
private prismHighlightScheduled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while the list should keep auto-pinning to the newest message. Set
|
* True while the list should keep auto-pinning to the newest message. Set
|
||||||
* when the conversation opens and whenever the user is scrolled near the
|
* when the conversation opens and whenever the user is scrolled near the
|
||||||
@@ -199,13 +233,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
* latest message.
|
* latest message.
|
||||||
*/
|
*/
|
||||||
private stickToBottom = true;
|
private stickToBottom = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timestamp (ms) until which a freshly opened conversation is still
|
* Timestamp (ms) until which a freshly opened conversation is still
|
||||||
* settling. Inside this window new messages jump instantly instead of
|
* settling. Inside this window new messages jump instantly instead of
|
||||||
* animating, so a channel switch always lands at the bottom.
|
* animating, so a channel switch always lands at the bottom.
|
||||||
*/
|
*/
|
||||||
private settleUntil = 0;
|
private settleUntil = 0;
|
||||||
private static readonly INITIAL_SETTLE_MS = 1500;
|
|
||||||
/**
|
/**
|
||||||
* Set when an older-page DB fetch is in flight. While true, the
|
* Set when an older-page DB fetch is in flight. While true, the
|
||||||
* `onMessagesChanged` effect treats incoming message-count growth as a
|
* `onMessagesChanged` effect treats incoming message-count growth as a
|
||||||
@@ -710,4 +745,5 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
|
|
||||||
return String(hash);
|
return String(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
@@ -55,27 +54,44 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
|
|||||||
templateUrl: './klipy-gif-picker.component.html'
|
templateUrl: './klipy-gif-picker.component.html'
|
||||||
})
|
})
|
||||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
|
||||||
|
|
||||||
readonly gifSelected = output<KlipyGif>();
|
|
||||||
readonly closed = output<undefined>();
|
|
||||||
|
|
||||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
readonly gifSelected = output<KlipyGif>();
|
||||||
|
|
||||||
|
readonly closed = output<undefined>();
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
private currentPage = 1;
|
|
||||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private requestId = 0;
|
|
||||||
|
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
|
|
||||||
results = signal<KlipyGif[]>([]);
|
results = signal<KlipyGif[]>([]);
|
||||||
|
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
|
|
||||||
errorMessage = signal('');
|
errorMessage = signal('');
|
||||||
|
|
||||||
hasNext = signal(false);
|
hasNext = signal(false);
|
||||||
|
|
||||||
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private currentPage = 1;
|
||||||
|
|
||||||
|
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private requestId = 0;
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
void this.loadResults(true);
|
void this.loadResults(true);
|
||||||
}
|
}
|
||||||
@@ -91,11 +107,6 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
this.clearSearchTimer();
|
this.clearSearchTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
|
||||||
onEscape(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearchQueryChanged(query: string): void {
|
onSearchQueryChanged(query: string): void {
|
||||||
this.searchQuery = query;
|
this.searchQuery = query;
|
||||||
this.clearSearchTimer();
|
this.clearSearchTimer();
|
||||||
@@ -206,4 +217,5 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight))
|
height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -44,17 +43,10 @@ interface TypingSignalingMessage {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class TypingIndicatorComponent {
|
export class TypingIndicatorComponent {
|
||||||
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private lastRoomId: string | null = null;
|
|
||||||
private lastConversationKey: string | null = null;
|
|
||||||
|
|
||||||
typingDisplay = signal<string[]>([]);
|
typingDisplay = signal<string[]>([]);
|
||||||
|
|
||||||
typingOthersCount = signal<number>(0);
|
typingOthersCount = signal<number>(0);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly typingLabel = computed(() => {
|
readonly typingLabel = computed(() => {
|
||||||
const names = this.typingDisplay();
|
const names = this.typingDisplay();
|
||||||
@@ -80,6 +72,22 @@ export class TypingIndicatorComponent {
|
|||||||
return this.appI18n.instant('chat.typing.many', { names: namesText });
|
return this.appI18n.instant('chat.typing.many', { names: namesText });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private lastRoomId: string | null = null;
|
||||||
|
|
||||||
|
private lastConversationKey: string | null = null;
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const webrtc = inject(RealtimeSessionFacade);
|
const webrtc = inject(RealtimeSessionFacade);
|
||||||
const destroyRef = inject(DestroyRef);
|
const destroyRef = inject(DestroyRef);
|
||||||
@@ -167,4 +175,5 @@ export class TypingIndicatorComponent {
|
|||||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -67,20 +66,32 @@ import { DirectMessageService } from '../../../direct-message';
|
|||||||
* Displays the list of online users with voice state indicators and admin actions.
|
* Displays the list of online users with voice state indicators and admin actions.
|
||||||
*/
|
*/
|
||||||
export class UserListComponent {
|
export class UserListComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private directMessages = inject(DirectMessageService);
|
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||||
|
|
||||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
||||||
|
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
|
||||||
showUserMenu = signal<string | null>(null);
|
showUserMenu = signal<string | null>(null);
|
||||||
|
|
||||||
showBanDialog = signal(false);
|
showBanDialog = signal(false);
|
||||||
|
|
||||||
userToBan = signal<User | null>(null);
|
userToBan = signal<User | null>(null);
|
||||||
|
|
||||||
banReason = '';
|
banReason = '';
|
||||||
banDuration = '86400000'; // Default 1 day
|
|
||||||
|
banDuration = '86400000';
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
|
// Default 1 day
|
||||||
|
|
||||||
statusLabel(status: User['status']): string {
|
statusLabel(status: User['status']): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -167,4 +178,5 @@ export class UserListComponent {
|
|||||||
|
|
||||||
this.closeBanDialog();
|
this.closeBanDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { createEffect } from '@ngrx/effects';
|
import { createEffect } from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -25,13 +24,6 @@ import { CustomEmojiService } from './custom-emoji.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomEmojiSyncEffects {
|
export class CustomEmojiSyncEffects {
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private readonly incomingEvents$ = merge(
|
|
||||||
this.webrtc.onMessageReceived,
|
|
||||||
this.webrtc.onSignalingMessage as Observable<ChatEvent>
|
|
||||||
);
|
|
||||||
|
|
||||||
currentUserLoad$ = createEffect(
|
currentUserLoad$ = createEffect(
|
||||||
() => this.store.select(selectCurrentUser).pipe(
|
() => this.store.select(selectCurrentUser).pipe(
|
||||||
@@ -124,4 +116,16 @@ export class CustomEmojiSyncEffects {
|
|||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly incomingEvents$ = merge(
|
||||||
|
this.webrtc.onMessageReceived,
|
||||||
|
this.webrtc.onSignalingMessage as Observable<ChatEvent>
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -43,25 +42,34 @@ interface PendingCustomEmojiTransfer {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CustomEmojiService {
|
export class CustomEmojiService {
|
||||||
private readonly db = inject(DatabaseService);
|
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private readonly emojisState = signal<CustomEmoji[]>([]);
|
|
||||||
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
|
|
||||||
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
|
|
||||||
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
|
|
||||||
private activeUserId: string | null = null;
|
|
||||||
private loaded = false;
|
|
||||||
|
|
||||||
readonly emojis = computed(() => {
|
readonly emojis = computed(() => {
|
||||||
const savedIds = this.savedIdsState();
|
const savedIds = this.savedIdsState();
|
||||||
|
|
||||||
return this.emojisState().filter((emoji) => savedIds.has(emoji.id));
|
return this.emojisState().filter((emoji) => savedIds.has(emoji.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({
|
readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({
|
||||||
customEmojis: this.emojis(),
|
customEmojis: this.emojis(),
|
||||||
usage: this.usageState()
|
usage: this.usageState()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly emojisState = signal<CustomEmoji[]>([]);
|
||||||
|
|
||||||
|
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
|
||||||
|
|
||||||
|
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
|
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
|
||||||
|
|
||||||
|
private activeUserId: string | null = null;
|
||||||
|
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
async loadForUser(userId: string | null | undefined): Promise<void> {
|
async loadForUser(userId: string | null | undefined): Promise<void> {
|
||||||
this.activeUserId = userId ?? null;
|
this.activeUserId = userId ?? null;
|
||||||
|
|
||||||
@@ -574,4 +582,5 @@ export class CustomEmojiService {
|
|||||||
|
|
||||||
return baseName || 'emoji';
|
return baseName || 'emoji';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -46,41 +45,74 @@ import {
|
|||||||
templateUrl: './custom-emoji-picker.component.html'
|
templateUrl: './custom-emoji-picker.component.html'
|
||||||
})
|
})
|
||||||
export class CustomEmojiPickerComponent {
|
export class CustomEmojiPickerComponent {
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
|
|
||||||
readonly currentUserId = input<string | null>(null);
|
readonly currentUserId = input<string | null>(null);
|
||||||
|
|
||||||
readonly compact = input(true);
|
readonly compact = input(true);
|
||||||
|
|
||||||
/** Render the picker panel in normal document flow for bottom-sheet embedding. */
|
/** Render the picker panel in normal document flow for bottom-sheet embedding. */
|
||||||
readonly inline = input(false);
|
readonly inline = input(false);
|
||||||
|
|
||||||
readonly emojiSelected = output<string>();
|
readonly emojiSelected = output<string>();
|
||||||
|
|
||||||
readonly dismissed = output();
|
readonly dismissed = output();
|
||||||
|
|
||||||
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
|
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
|
||||||
|
|
||||||
readonly modalOpen = signal(false);
|
readonly modalOpen = signal(false);
|
||||||
|
|
||||||
readonly uploadError = signal<string | null>(null);
|
readonly uploadError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly uploading = signal(false);
|
readonly uploading = signal(false);
|
||||||
|
|
||||||
readonly shortcuts = this.customEmoji.shortcutEntries;
|
readonly shortcuts = this.customEmoji.shortcutEntries;
|
||||||
|
|
||||||
readonly customEmojis = this.customEmoji.emojis;
|
readonly customEmojis = this.customEmoji.emojis;
|
||||||
|
|
||||||
readonly searchQuery = signal('');
|
readonly searchQuery = signal('');
|
||||||
|
|
||||||
readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries(
|
readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries(
|
||||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||||
this.searchQuery()
|
this.searchQuery()
|
||||||
));
|
));
|
||||||
|
|
||||||
readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker(
|
readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker(
|
||||||
this.customEmojis(),
|
this.customEmojis(),
|
||||||
this.searchQuery()
|
this.searchQuery()
|
||||||
));
|
));
|
||||||
|
|
||||||
readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0);
|
readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0);
|
||||||
|
|
||||||
readonly showEmptySearchState = computed(() => this.hasActiveSearch()
|
readonly showEmptySearchState = computed(() => this.hasActiveSearch()
|
||||||
&& this.filteredUnicodeEntries().length === 0
|
&& this.filteredUnicodeEntries().length === 0
|
||||||
&& this.filteredCustomEmojis().length === 0);
|
&& this.filteredCustomEmojis().length === 0);
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
|
||||||
private readonly loadForUser = effect(() => {
|
private readonly loadForUser = effect(() => {
|
||||||
void this.customEmoji.loadForUser(this.currentUserId());
|
void this.customEmoji.loadForUser(this.currentUserId());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent): void {
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (target == null || this.host.nativeElement.contains(target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
setSearchQuery(query: string): void {
|
setSearchQuery(query: string): void {
|
||||||
this.searchQuery.set(query);
|
this.searchQuery.set(query);
|
||||||
}
|
}
|
||||||
@@ -114,22 +146,6 @@ export class CustomEmojiPickerComponent {
|
|||||||
this.modalOpen.set(false);
|
this.modalOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
|
||||||
onDocumentClick(event: MouseEvent): void {
|
|
||||||
const target = event.target;
|
|
||||||
|
|
||||||
if (target == null || this.host.nativeElement.contains(target as Node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dismiss();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
|
||||||
onEscape(): void {
|
|
||||||
this.dismiss();
|
|
||||||
}
|
|
||||||
|
|
||||||
openModal(): void {
|
openModal(): void {
|
||||||
this.modalOpen.set(true);
|
this.modalOpen.set(true);
|
||||||
}
|
}
|
||||||
@@ -168,4 +184,5 @@ export class CustomEmojiPickerComponent {
|
|||||||
this.uploading.set(false);
|
this.uploading.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -37,31 +36,13 @@ import { toDirectMessageParticipant } from '../../../direct-message';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DirectCallService {
|
export class DirectCallService {
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly delivery = inject(PeerDeliveryService);
|
|
||||||
private readonly directMessages = inject(DirectMessageService);
|
|
||||||
private readonly audio = inject(NotificationAudioService);
|
|
||||||
private readonly voice = inject(VoiceConnectionFacade);
|
|
||||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
|
||||||
private readonly playback = inject(VoicePlaybackService);
|
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private readonly mobileNotifications = inject(MobileNotificationsService);
|
|
||||||
private readonly mobileCallSession = inject(MobileCallSessionService);
|
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
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);
|
|
||||||
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
|
|
||||||
private readonly declinedCallIds = new Set<string>();
|
|
||||||
|
|
||||||
readonly sessions = computed(() => this.sessionsSignal());
|
readonly sessions = computed(() => this.sessionsSignal());
|
||||||
|
|
||||||
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
|
||||||
|
|
||||||
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
|
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
|
||||||
|
|
||||||
readonly incomingCall = computed<DirectCallSession | null>(() => {
|
readonly incomingCall = computed<DirectCallSession | null>(() => {
|
||||||
if (this.isDoNotDisturb()) {
|
if (this.isDoNotDisturb()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -80,8 +61,11 @@ export class DirectCallService {
|
|||||||
&& !session.participants[meId]?.joined
|
&& !session.participants[meId]?.joined
|
||||||
&& this.hasConnectedParticipant(session)) ?? null;
|
&& this.hasConnectedParticipant(session)) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||||
|
|
||||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||||
|
|
||||||
readonly mobileOverlaySession = computed(() => {
|
readonly mobileOverlaySession = computed(() => {
|
||||||
const callId = this.mobileOverlayCallId();
|
const callId = this.mobileOverlayCallId();
|
||||||
|
|
||||||
@@ -92,6 +76,48 @@ export class DirectCallService {
|
|||||||
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
|
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly delivery = inject(PeerDeliveryService);
|
||||||
|
|
||||||
|
private readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
|
private readonly audio = inject(NotificationAudioService);
|
||||||
|
|
||||||
|
private readonly voice = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
|
private readonly playback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||||
|
|
||||||
|
private readonly mobileCallSession = inject(MobileCallSessionService);
|
||||||
|
|
||||||
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
|
||||||
|
|
||||||
|
private readonly declinedCallIds = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mobileCallSession.initialize();
|
this.mobileCallSession.initialize();
|
||||||
this.mobileCallSession.onCallControlAction((intent, callId) => {
|
this.mobileCallSession.onCallControlAction((intent, callId) => {
|
||||||
@@ -392,19 +418,6 @@ export class DirectCallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
|
||||||
const action = endForEveryone ? 'end' : 'leave';
|
|
||||||
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
|
||||||
|
|
||||||
this.audio.stop(AppSound.Call);
|
|
||||||
this.broadcastCallEvent(action, nextSession);
|
|
||||||
this.stopLocalMedia(nextSession);
|
|
||||||
this.upsertSession(nextSession);
|
|
||||||
|
|
||||||
this.currentSession.set(null);
|
|
||||||
void this.mobileCallSession.endActiveCall(session.callId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async inviteUser(callId: string, user: User): Promise<void> {
|
async inviteUser(callId: string, user: User): Promise<void> {
|
||||||
const session = this.sessionById(callId);
|
const session = this.sessionById(callId);
|
||||||
|
|
||||||
@@ -445,6 +458,19 @@ export class DirectCallService {
|
|||||||
return participant ? participantToUser(participant) : null;
|
return participant ? participantToUser(participant) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
|
||||||
|
const action = endForEveryone ? 'end' : 'leave';
|
||||||
|
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
|
||||||
|
|
||||||
|
this.audio.stop(AppSound.Call);
|
||||||
|
this.broadcastCallEvent(action, nextSession);
|
||||||
|
this.stopLocalMedia(nextSession);
|
||||||
|
this.upsertSession(nextSession);
|
||||||
|
|
||||||
|
this.currentSession.set(null);
|
||||||
|
void this.mobileCallSession.endActiveCall(session.callId);
|
||||||
|
}
|
||||||
|
|
||||||
private async drainPendingIncomingCallPayloads(): Promise<void> {
|
private async drainPendingIncomingCallPayloads(): Promise<void> {
|
||||||
if (this.pendingIncomingCallPayloads.length === 0) {
|
if (this.pendingIncomingCallPayloads.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1041,4 +1067,5 @@ export class DirectCallService {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,13 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct
|
|||||||
})
|
})
|
||||||
export class IncomingCallModalComponent {
|
export class IncomingCallModalComponent {
|
||||||
readonly calls = inject(DirectCallService);
|
readonly calls = inject(DirectCallService);
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly session = this.calls.incomingCall;
|
readonly session = this.calls.incomingCall;
|
||||||
|
|
||||||
readonly answering = signal(false);
|
readonly answering = signal(false);
|
||||||
|
|
||||||
readonly caller = computed(() => {
|
readonly caller = computed(() => {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
|
|
||||||
@@ -53,10 +56,13 @@ export class IncomingCallModalComponent {
|
|||||||
return (callerId ? this.calls.userForParticipant(callerId) : null)
|
return (callerId ? this.calls.userForParticipant(callerId) : null)
|
||||||
?? (participant ? participantToUser(participant) : null);
|
?? (participant ? participantToUser(participant) : null);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone'));
|
readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone'));
|
||||||
|
|
||||||
readonly callerCallingLabel = computed(() =>
|
readonly callerCallingLabel = computed(() =>
|
||||||
this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() })
|
this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() })
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly callKindLabel = computed(() => {
|
readonly callKindLabel = computed(() => {
|
||||||
const participantCount = this.session()?.participantIds.length ?? 0;
|
const participantCount = this.session()?.participantIds.length ?? 0;
|
||||||
|
|
||||||
@@ -65,6 +71,8 @@ export class IncomingCallModalComponent {
|
|||||||
: this.i18n.instant('call.incoming.directCall');
|
: this.i18n.instant('call.incoming.directCall');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscape(): void {
|
onEscape(): void {
|
||||||
this.decline();
|
this.decline();
|
||||||
@@ -115,4 +123,5 @@ export class IncomingCallModalComponent {
|
|||||||
private userKey(user: User): string {
|
private userKey(user: User): string {
|
||||||
return user.oderId || user.id;
|
return user.oderId || user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -62,24 +61,13 @@ interface DirectMessageTypingEntry {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DirectMessageService {
|
export class DirectMessageService {
|
||||||
private readonly repository = inject(DirectMessageRepository);
|
|
||||||
private readonly offlineQueue = inject(OfflineMessageQueueService);
|
|
||||||
private readonly delivery = inject(PeerDeliveryService);
|
|
||||||
private readonly attachments = inject(AttachmentFacade);
|
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
|
||||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
|
||||||
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
|
||||||
private readonly lastSyncRequestAt = new Map<string, number>();
|
|
||||||
private loadedOwnerId: string | null = null;
|
|
||||||
|
|
||||||
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
|
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
|
||||||
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
|
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
|
||||||
));
|
));
|
||||||
|
|
||||||
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
|
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
|
||||||
|
|
||||||
readonly selectedConversation = computed(() => {
|
readonly selectedConversation = computed(() => {
|
||||||
const selectedId = this.selectedConversationIdSignal();
|
const selectedId = this.selectedConversationIdSignal();
|
||||||
|
|
||||||
@@ -87,12 +75,40 @@ export class DirectMessageService {
|
|||||||
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
|
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
|
||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
|
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
|
||||||
(total, conversation) => total + conversation.unreadCount,
|
(total, conversation) => total + conversation.unreadCount,
|
||||||
0
|
0
|
||||||
));
|
));
|
||||||
|
|
||||||
readonly typingEntries = this.typingEntriesSignal.asReadonly();
|
readonly typingEntries = this.typingEntriesSignal.asReadonly();
|
||||||
|
|
||||||
|
private readonly repository = inject(DirectMessageRepository);
|
||||||
|
|
||||||
|
private readonly offlineQueue = inject(OfflineMessageQueueService);
|
||||||
|
|
||||||
|
private readonly delivery = inject(PeerDeliveryService);
|
||||||
|
|
||||||
|
private readonly attachments = inject(AttachmentFacade);
|
||||||
|
|
||||||
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||||
|
|
||||||
|
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
|
||||||
|
|
||||||
|
private readonly lastSyncRequestAt = new Map<string, number>();
|
||||||
|
|
||||||
|
private loadedOwnerId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const ownerId = this.getCurrentUserId();
|
const ownerId = this.getCurrentUserId();
|
||||||
@@ -991,4 +1007,5 @@ export class DirectMessageService {
|
|||||||
|
|
||||||
return ownerId;
|
return ownerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -14,16 +13,23 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class FriendService {
|
export class FriendService {
|
||||||
private readonly repository = inject(FriendRepository);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
private readonly friendsSignal = signal<Friend[]>([]);
|
|
||||||
private loadedOwnerId: string | null = null;
|
|
||||||
|
|
||||||
readonly friends = this.friendsSignal.asReadonly();
|
readonly friends = this.friendsSignal.asReadonly();
|
||||||
|
|
||||||
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId)));
|
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId)));
|
||||||
|
|
||||||
|
private readonly repository = inject(FriendRepository);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
private readonly friendsSignal = signal<Friend[]>([]);
|
||||||
|
|
||||||
|
private loadedOwnerId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
|
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
|
||||||
@@ -116,4 +122,5 @@ export class FriendService {
|
|||||||
await this.loadForOwner(ownerId);
|
await this.loadForOwner(ownerId);
|
||||||
return ownerId;
|
return ownerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +12,6 @@ import type { ChatEvent, User } from '../../../../shared-kernel';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PeerDeliveryService {
|
export class PeerDeliveryService {
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
|
||||||
private readonly networkRestoredSubject = new Subject<void>();
|
|
||||||
|
|
||||||
readonly directMessageEvents$: Observable<ChatEvent> = merge(
|
readonly directMessageEvents$: Observable<ChatEvent> = merge(
|
||||||
this.webrtc.onMessageReceived,
|
this.webrtc.onMessageReceived,
|
||||||
@@ -38,8 +33,17 @@ export class PeerDeliveryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
readonly peerConnected$ = this.webrtc.onPeerConnected;
|
readonly peerConnected$ = this.webrtc.onPeerConnected;
|
||||||
|
|
||||||
readonly networkRestored$ = this.networkRestoredSubject.asObservable();
|
readonly networkRestored$ = this.networkRestoredSubject.asObservable();
|
||||||
|
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
|
private readonly networkRestoredSubject = new Subject<void>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.installNetworkTestHooks();
|
this.installNetworkTestHooks();
|
||||||
}
|
}
|
||||||
@@ -174,4 +178,5 @@ export class PeerDeliveryService {
|
|||||||
this.networkRestoredSubject.next();
|
this.networkRestoredSubject.next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -84,55 +83,68 @@ interface DmStatusLabel {
|
|||||||
export class DmChatComponent {
|
export class DmChatComponent {
|
||||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
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;
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly directCalls = inject(DirectCallService);
|
readonly directCalls = inject(DirectCallService);
|
||||||
|
|
||||||
readonly directMessages = inject(DirectMessageService);
|
readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
readonly showGifPicker = signal(false);
|
readonly showGifPicker = signal(false);
|
||||||
|
|
||||||
readonly conversationId = input<string | null>(null);
|
readonly conversationId = input<string | null>(null);
|
||||||
|
|
||||||
readonly showCallButton = input(true);
|
readonly showCallButton = input(true);
|
||||||
|
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
|
|
||||||
readonly gifPickerAnchorRight = signal(16);
|
readonly gifPickerAnchorRight = signal(16);
|
||||||
|
|
||||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||||
|
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
|
|
||||||
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
readonly lightboxState = signal<ChatLightboxState | null>(null);
|
||||||
|
|
||||||
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
readonly galleryAttachments = signal<Attachment[] | null>(null);
|
||||||
|
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
|
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
|
||||||
|
|
||||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
||||||
|
|
||||||
readonly conversation = this.directMessages.selectedConversation;
|
readonly conversation = this.directMessages.selectedConversation;
|
||||||
|
|
||||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
|
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
|
||||||
|
|
||||||
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
|
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
|
||||||
|
|
||||||
readonly typingUsers = computed(() => {
|
readonly typingUsers = computed(() => {
|
||||||
void this.directMessages.typingEntries();
|
void this.directMessages.typingEntries();
|
||||||
|
|
||||||
return this.directMessages.typingUsers(this.conversation()?.id);
|
return this.directMessages.typingUsers(this.conversation()?.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly peerUser = computed(() => {
|
readonly peerUser = computed(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
|
|
||||||
return conversation ? this.peerUserFor(conversation) : null;
|
return conversation ? this.peerUserFor(conversation) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isGroupConversation = computed(() => {
|
readonly isGroupConversation = computed(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
|
|
||||||
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
|
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly participantUsers = computed<User[]>(() => {
|
readonly participantUsers = computed<User[]>(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
const knownUsers = this.allUsers();
|
const knownUsers = this.allUsers();
|
||||||
@@ -164,6 +176,7 @@ export class DmChatComponent {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
const currentUserId = this.currentUserId();
|
const currentUserId = this.currentUserId();
|
||||||
@@ -179,6 +192,7 @@ export class DmChatComponent {
|
|||||||
status: message.status
|
status: message.status
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly chatMessages = computed<Message[]>(() => {
|
readonly chatMessages = computed<Message[]>(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
const metadataByMessageId = this.linkMetadataByMessageId();
|
const metadataByMessageId = this.linkMetadataByMessageId();
|
||||||
@@ -196,7 +210,11 @@ export class DmChatComponent {
|
|||||||
roomId: conversation.id,
|
roomId: conversation.id,
|
||||||
channelId: 'direct-message',
|
channelId: 'direct-message',
|
||||||
senderId: message.senderId,
|
senderId: message.senderId,
|
||||||
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? this.i18n.instant('common.labels.you') : message.senderId),
|
senderName: knownUser?.displayName
|
||||||
|
|| participant?.displayName
|
||||||
|
|| (message.senderId === this.currentUserId()
|
||||||
|
? this.i18n.instant('common.labels.you')
|
||||||
|
: message.senderId),
|
||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
kind: message.kind,
|
kind: message.kind,
|
||||||
@@ -209,6 +227,7 @@ export class DmChatComponent {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly peerName = computed(() => {
|
readonly peerName = computed(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
const currentUserId = this.currentUserId();
|
const currentUserId = this.currentUserId();
|
||||||
@@ -221,6 +240,7 @@ export class DmChatComponent {
|
|||||||
|
|
||||||
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
|
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly peerCallIcon = computed(() => {
|
readonly peerCallIcon = computed(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
|
|
||||||
@@ -232,6 +252,7 @@ export class DmChatComponent {
|
|||||||
|
|
||||||
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly canCallConversation = computed(() => {
|
readonly canCallConversation = computed(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
|
|
||||||
@@ -246,6 +267,28 @@ export class DmChatComponent {
|
|||||||
return !!this.peerUser();
|
return !!this.peerUser();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const conversationId = this.effectiveConversationId();
|
const conversationId = this.effectiveConversationId();
|
||||||
@@ -659,4 +702,5 @@ export class DmChatComponent {
|
|||||||
|
|
||||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -60,15 +59,16 @@ const EXIT_ANIMATION_MS = 160;
|
|||||||
styleUrl: './dm-rail.component.scss'
|
styleUrl: './dm-rail.component.scss'
|
||||||
})
|
})
|
||||||
export class DmRailComponent implements OnDestroy {
|
export class DmRailComponent implements OnDestroy {
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
readonly directMessages = inject(DirectMessageService);
|
readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
readonly friends = inject(FriendService);
|
readonly friends = inject(FriendService);
|
||||||
|
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
||||||
|
|
||||||
readonly activeConversationId = toSignal(
|
readonly activeConversationId = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -76,11 +76,15 @@ export class DmRailComponent implements OnDestroy {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
|
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly friendUsers = computed(() => this.users().filter((user) =>
|
readonly friendUsers = computed(() => this.users().filter((user) =>
|
||||||
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
||||||
));
|
));
|
||||||
|
|
||||||
readonly railItems = signal<DmRailItem[]>([]);
|
readonly railItems = signal<DmRailItem[]>([]);
|
||||||
|
|
||||||
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
|
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
|
||||||
|
|
||||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
||||||
const currentUserId = this.currentUserId();
|
const currentUserId = this.currentUserId();
|
||||||
const items = new Map<string, DmRailItem>();
|
const items = new Map<string, DmRailItem>();
|
||||||
@@ -143,6 +147,7 @@ export class DmRailComponent implements OnDestroy {
|
|||||||
|
|
||||||
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
|
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isOnDirectMessages = toSignal(
|
readonly isOnDirectMessages = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -151,6 +156,14 @@ export class DmRailComponent implements OnDestroy {
|
|||||||
{ initialValue: this.router.url.startsWith('/dm') }
|
{ initialValue: this.router.url.startsWith('/dm') }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const unreadItems = this.unreadRailItems();
|
const unreadItems = this.unreadRailItems();
|
||||||
@@ -334,4 +347,5 @@ export class DmRailComponent implements OnDestroy {
|
|||||||
|
|
||||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -44,26 +43,42 @@ import { DirectMessageService } from '../../application/services/direct-message.
|
|||||||
templateUrl: './dm-conversation-item.component.html'
|
templateUrl: './dm-conversation-item.component.html'
|
||||||
})
|
})
|
||||||
export class DmConversationItemComponent {
|
export class DmConversationItemComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly attachments = inject(AttachmentFacade);
|
|
||||||
private readonly directMessages = inject(DirectMessageService);
|
|
||||||
private readonly directCalls = inject(DirectCallService);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
readonly conversation = input.required<DirectMessageConversation>();
|
readonly conversation = input.required<DirectMessageConversation>();
|
||||||
|
|
||||||
readonly conversationOpened = output<string>();
|
readonly conversationOpened = output<string>();
|
||||||
|
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
|
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
|
||||||
|
|
||||||
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
|
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
|
||||||
|
|
||||||
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
|
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
|
||||||
|
|
||||||
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
|
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
|
||||||
|
|
||||||
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
|
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
|
||||||
|
|
||||||
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
|
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly attachments = inject(AttachmentFacade);
|
||||||
|
|
||||||
|
private readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
|
private readonly directCalls = inject(DirectCallService);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const conversation = this.conversation();
|
const conversation = this.conversation();
|
||||||
@@ -118,7 +133,12 @@ export class DmConversationItemComponent {
|
|||||||
const peerId = this.peerId(conversation);
|
const peerId = this.peerId(conversation);
|
||||||
const knownUser = this.peerUser(conversation);
|
const knownUser = this.peerUser(conversation);
|
||||||
|
|
||||||
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
|
if (!peerId)
|
||||||
|
return this.i18n.instant('dm.chat.defaultTitle');
|
||||||
|
|
||||||
|
return knownUser?.displayName
|
||||||
|
|| conversation.participantProfiles[peerId]?.displayName
|
||||||
|
|| peerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
||||||
@@ -229,4 +249,5 @@ export class DmConversationItemComponent {
|
|||||||
? this.i18n.instant('dm.previews.oneAttachment')
|
? this.i18n.instant('dm.previews.oneAttachment')
|
||||||
: this.i18n.instant('dm.previews.manyAttachments');
|
: this.i18n.instant('dm.previews.manyAttachments');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -33,12 +32,16 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
|
|||||||
templateUrl: './dm-conversations-panel.component.html'
|
templateUrl: './dm-conversations-panel.component.html'
|
||||||
})
|
})
|
||||||
export class DmConversationsPanelComponent {
|
export class DmConversationsPanelComponent {
|
||||||
private readonly theme = inject(ThemeService);
|
|
||||||
readonly directMessages = inject(DirectMessageService);
|
readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||||
|
|
||||||
readonly conversationSelected = output<string>();
|
readonly conversationSelected = output<string>();
|
||||||
|
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
|
|
||||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||||
return conversation.id;
|
return conversation.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Component,
|
Component,
|
||||||
@@ -55,21 +54,18 @@ interface SwiperElement extends HTMLElement {
|
|||||||
templateUrl: './dm-workspace.component.html'
|
templateUrl: './dm-workspace.component.html'
|
||||||
})
|
})
|
||||||
export class DmWorkspaceComponent implements OnDestroy {
|
export class DmWorkspaceComponent implements OnDestroy {
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly 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 directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||||
|
|
||||||
readonly activeCall = computed(() => {
|
readonly activeCall = computed(() => {
|
||||||
const currentSession = this.directCalls.currentSession();
|
const currentSession = this.directCalls.currentSession();
|
||||||
const visibleSessions = this.directCalls.visibleActiveSessions();
|
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||||
@@ -80,6 +76,22 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||||
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
|
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const conversationId = this.routeConversationId();
|
const conversationId = this.routeConversationId();
|
||||||
@@ -171,5 +183,6 @@ export class DmWorkspaceComponent implements OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.directMessages.closeConversationView(this.routeConversationId());
|
this.directMessages.closeConversationView(this.routeConversationId());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -16,7 +15,7 @@ import {
|
|||||||
lucideUsers
|
lucideUsers
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||||
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
||||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||||
@@ -47,10 +46,7 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class FindPeopleComponent {
|
export class FindPeopleComponent {
|
||||||
private store = inject(Store);
|
|
||||||
searchQuery = signal('');
|
searchQuery = signal('');
|
||||||
private users = this.store.selectSignal(selectAllUsers);
|
|
||||||
private savedRooms = this.store.selectSignal(selectSavedRooms);
|
|
||||||
|
|
||||||
/** True when the account has any people to surface (known users or server members). */
|
/** True when the account has any people to surface (known users or server members). */
|
||||||
hasDiscoverablePeople = computed(() => {
|
hasDiscoverablePeople = computed(() => {
|
||||||
@@ -61,7 +57,14 @@ export class FindPeopleComponent {
|
|||||||
return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0);
|
return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
|
private savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
onSearchChange(query: string): void {
|
onSearchChange(query: string): void {
|
||||||
this.searchQuery.set(query);
|
this.searchQuery.set(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -24,20 +23,26 @@ import type { User } from '../../../../shared-kernel';
|
|||||||
templateUrl: './friend-button.component.html'
|
templateUrl: './friend-button.component.html'
|
||||||
})
|
})
|
||||||
export class FriendButtonComponent {
|
export class FriendButtonComponent {
|
||||||
private readonly friends = inject(FriendService);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly user = input.required<User>();
|
readonly user = input.required<User>();
|
||||||
|
|
||||||
readonly userId = computed(() => this.user().oderId || this.user().id);
|
readonly userId = computed(() => this.user().oderId || this.user().id);
|
||||||
|
|
||||||
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
|
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
|
||||||
|
|
||||||
readonly ariaLabel = computed(() =>
|
readonly ariaLabel = computed(() =>
|
||||||
this.isFriend()
|
this.isFriend()
|
||||||
? this.i18n.instant('dm.friend.remove')
|
? this.i18n.instant('dm.friend.remove')
|
||||||
: this.i18n.instant('dm.friend.add')
|
: this.i18n.instant('dm.friend.add')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly friends = inject(FriendService);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
toggle(event: Event): void {
|
toggle(event: Event): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
void this.friends.toggleFriend(this.userId());
|
void this.friends.toggleFriend(this.userId());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -39,16 +38,18 @@ import type { User } from '../../../../shared-kernel';
|
|||||||
templateUrl: './user-search-list.component.html'
|
templateUrl: './user-search-list.component.html'
|
||||||
})
|
})
|
||||||
export class UserSearchListComponent {
|
export class UserSearchListComponent {
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly directMessages = inject(DirectMessageService);
|
|
||||||
readonly directCalls = inject(DirectCallService);
|
readonly directCalls = inject(DirectCallService);
|
||||||
|
|
||||||
readonly friends = inject(FriendService);
|
readonly friends = inject(FriendService);
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
readonly searchQuery = input('');
|
readonly searchQuery = input('');
|
||||||
|
|
||||||
readonly users = this.store.selectSignal(selectAllUsers);
|
readonly users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly discoveredUsers = computed(() => {
|
readonly discoveredUsers = computed(() => {
|
||||||
const usersById = new Map<string, User>();
|
const usersById = new Map<string, User>();
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export class UserSearchListComponent {
|
|||||||
|
|
||||||
return Array.from(usersById.values());
|
return Array.from(usersById.values());
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly matchingUsers = computed(() => {
|
readonly matchingUsers = computed(() => {
|
||||||
const query = this.normalizedSearchQuery();
|
const query = this.normalizedSearchQuery();
|
||||||
const currentUserId = this.currentUserKey();
|
const currentUserId = this.currentUserKey();
|
||||||
@@ -90,13 +92,23 @@ export class UserSearchListComponent {
|
|||||||
.filter((user) => this.matchesQuery(user, query))
|
.filter((user) => this.matchesQuery(user, query))
|
||||||
.slice(0, 24);
|
.slice(0, 24);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
|
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
|
||||||
|
|
||||||
readonly results = computed(() => {
|
readonly results = computed(() => {
|
||||||
const friendIds = this.friends.friendIds();
|
const friendIds = this.friends.friendIds();
|
||||||
|
|
||||||
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
|
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
async messageUser(user: User): Promise<void> {
|
async messageUser(user: User): Promise<void> {
|
||||||
const conversation = await this.directMessages.createConversation(user);
|
const conversation = await this.directMessages.createConversation(user);
|
||||||
|
|
||||||
@@ -151,4 +163,5 @@ export class UserSearchListComponent {
|
|||||||
.filter((value): value is string => !!value)
|
.filter((value): value is string => !!value)
|
||||||
.some((value) => value.toLowerCase().includes(query));
|
.some((value) => value.toLowerCase().includes(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
@@ -38,9 +37,6 @@ export function groupMessagesByRoom(messages: Message[]): Map<string, Message[]>
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsEffects {
|
export class NotificationsEffects {
|
||||||
private readonly actions$ = inject(Actions);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly notifications = inject(NotificationsFacade);
|
|
||||||
|
|
||||||
syncRoomCatalog$ = createEffect(
|
syncRoomCatalog$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
@@ -136,4 +132,11 @@ export class NotificationsEffects {
|
|||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly actions$ = inject(Actions);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly notifications = inject(NotificationsFacade);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { NotificationsService } from '../services/notifications.service';
|
import { NotificationsService } from '../services/notifications.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class NotificationsFacade {
|
export class NotificationsFacade {
|
||||||
private readonly service = inject(NotificationsService);
|
|
||||||
|
|
||||||
readonly settings = this.service.settings;
|
readonly settings = this.service.settings;
|
||||||
|
|
||||||
readonly unread = this.service.unread;
|
readonly unread = this.service.unread;
|
||||||
|
|
||||||
|
private readonly service = inject(NotificationsService);
|
||||||
|
|
||||||
initialize(
|
initialize(
|
||||||
...args: Parameters<NotificationsService['initialize']>
|
...args: Parameters<NotificationsService['initialize']>
|
||||||
): ReturnType<NotificationsService['initialize']> {
|
): ReturnType<NotificationsService['initialize']> {
|
||||||
@@ -98,4 +99,5 @@ export class NotificationsFacade {
|
|||||||
): ReturnType<NotificationsService['setChannelMuted']> {
|
): ReturnType<NotificationsService['setChannelMuted']> {
|
||||||
return this.service.setChannelMuted(...args);
|
return this.service.setChannelMuted(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -46,33 +45,54 @@ const MAX_NOTIFIED_MESSAGE_IDS = 500;
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
|
|
||||||
|
readonly settings = computed(() => this._settings());
|
||||||
|
|
||||||
|
readonly unread = computed(() => this._unread());
|
||||||
|
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
private readonly db = inject(DatabaseService);
|
private readonly db = inject(DatabaseService);
|
||||||
|
|
||||||
private readonly audio = inject(NotificationAudioService);
|
private readonly audio = inject(NotificationAudioService);
|
||||||
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
|
|
||||||
private readonly desktopNotifications = inject(DesktopNotificationService);
|
private readonly desktopNotifications = inject(DesktopNotificationService);
|
||||||
|
|
||||||
private readonly storage = inject(NotificationSettingsStorageService);
|
private readonly storage = inject(NotificationSettingsStorageService);
|
||||||
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
private readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
private readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings());
|
private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings());
|
||||||
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
|
|
||||||
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
|
|
||||||
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
|
||||||
private readonly _windowMinimized = signal<boolean>(false);
|
|
||||||
private readonly platformKind = detectPlatform();
|
|
||||||
private readonly notifiedMessageIds = new Set<string>();
|
|
||||||
private readonly notifiedMessageOrder: string[] = [];
|
|
||||||
private attentionActive = false;
|
|
||||||
private windowStateCleanup: (() => void) | null = null;
|
|
||||||
private initialised = false;
|
|
||||||
|
|
||||||
readonly settings = computed(() => this._settings());
|
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
|
||||||
readonly unread = computed(() => this._unread());
|
|
||||||
|
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
|
||||||
|
|
||||||
|
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
|
||||||
|
|
||||||
|
private readonly _windowMinimized = signal<boolean>(false);
|
||||||
|
|
||||||
|
private readonly platformKind = detectPlatform();
|
||||||
|
|
||||||
|
private readonly notifiedMessageIds = new Set<string>();
|
||||||
|
|
||||||
|
private readonly notifiedMessageOrder: string[] = [];
|
||||||
|
|
||||||
|
private attentionActive = false;
|
||||||
|
|
||||||
|
private windowStateCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
private initialised = false;
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.initialised) {
|
if (this.initialised) {
|
||||||
@@ -311,30 +331,6 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerWindowListeners(): void {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('focus', this.handleWindowFocus);
|
|
||||||
window.addEventListener('blur', this.handleWindowBlur);
|
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
private registerWindowStateListener(): void {
|
|
||||||
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
|
||||||
this._windowFocused.set(state.isFocused);
|
|
||||||
this._windowMinimized.set(state.isMinimized);
|
|
||||||
|
|
||||||
if (state.isFocused && !state.isMinimized && this._documentVisible()) {
|
|
||||||
this.markCurrentChannelReadIfActive();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.syncWindowAttention();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly handleWindowFocus = (): void => {
|
private readonly handleWindowFocus = (): void => {
|
||||||
this._windowFocused.set(true);
|
this._windowFocused.set(true);
|
||||||
this._windowMinimized.set(false);
|
this._windowMinimized.set(false);
|
||||||
@@ -359,6 +355,30 @@ export class NotificationsService {
|
|||||||
this.syncWindowAttention();
|
this.syncWindowAttention();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private registerWindowListeners(): void {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('focus', this.handleWindowFocus);
|
||||||
|
window.addEventListener('blur', this.handleWindowBlur);
|
||||||
|
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWindowStateListener(): void {
|
||||||
|
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
||||||
|
this._windowFocused.set(state.isFocused);
|
||||||
|
this._windowMinimized.set(state.isMinimized);
|
||||||
|
|
||||||
|
if (state.isFocused && !state.isMinimized && this._documentVisible()) {
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private buildContext(): NotificationDeliveryContext {
|
private buildContext(): NotificationDeliveryContext {
|
||||||
return {
|
return {
|
||||||
activeChannelId: this.activeChannelId(),
|
activeChannelId: this.activeChannelId(),
|
||||||
@@ -625,6 +645,7 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectPlatform(): DesktopPlatform {
|
function detectPlatform(): DesktopPlatform {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -37,15 +36,20 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
|
|||||||
templateUrl: './notifications-settings.component.html'
|
templateUrl: './notifications-settings.component.html'
|
||||||
})
|
})
|
||||||
export class NotificationsSettingsComponent {
|
export class NotificationsSettingsComponent {
|
||||||
private readonly store = inject(Store);
|
|
||||||
readonly notifications = inject(NotificationsFacade);
|
readonly notifications = inject(NotificationsFacade);
|
||||||
|
|
||||||
readonly rooms = this.store.selectSignal(selectSavedRooms);
|
readonly rooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
readonly settings = this.notifications.settings;
|
readonly settings = this.notifications.settings;
|
||||||
|
|
||||||
readonly enabled = computed(() => this.settings().enabled);
|
readonly enabled = computed(() => this.settings().enabled);
|
||||||
|
|
||||||
readonly showPreview = computed(() => this.settings().showPreview);
|
readonly showPreview = computed(() => this.settings().showPreview);
|
||||||
|
|
||||||
readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus);
|
readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
trackRoom = (_index: number, room: Room) => room.id;
|
trackRoom = (_index: number, room: Room) => room.id;
|
||||||
|
|
||||||
textChannels(room: Room) {
|
textChannels(room: Room) {
|
||||||
@@ -112,4 +116,5 @@ export class NotificationsSettingsComponent {
|
|||||||
formatUnreadCount(count: number): string {
|
formatUnreadCount(count: number): string {
|
||||||
return count > 99 ? '99+' : String(count);
|
return count > 99 ? '99+' : String(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -43,27 +42,15 @@ export interface PluginRequirementComparison {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginRequirementStateService {
|
export class PluginRequirementStateService {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly pluginRequirements = inject(PluginRequirementService);
|
|
||||||
private readonly pluginStore = inject(PluginStoreService);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
|
||||||
private readonly registry = inject(PluginRegistryService);
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
|
||||||
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
|
||||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
|
||||||
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
|
||||||
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
|
|
||||||
|
|
||||||
readonly currentSnapshot = computed(() => {
|
readonly currentSnapshot = computed(() => {
|
||||||
const roomId = this.currentRoomId();
|
const roomId = this.currentRoomId();
|
||||||
|
|
||||||
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
|
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
|
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
|
||||||
|
|
||||||
readonly missingInstallableRequirements = computed(() => {
|
readonly missingInstallableRequirements = computed(() => {
|
||||||
if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) {
|
if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) {
|
||||||
return [];
|
return [];
|
||||||
@@ -79,11 +66,14 @@ export class PluginRequirementStateService {
|
|||||||
|
|
||||||
return requirements;
|
return requirements;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
|
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
|
||||||
.filter((requirement) => requirement.status === 'required'));
|
.filter((requirement) => requirement.status === 'required'));
|
||||||
|
|
||||||
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
|
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
|
||||||
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
|
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
|
||||||
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
|
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
|
||||||
|
|
||||||
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
|
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
|
||||||
const snapshot = this.currentSnapshot();
|
const snapshot = this.currentSnapshot();
|
||||||
const installedEntries = this.registry.entries();
|
const installedEntries = this.registry.entries();
|
||||||
@@ -115,6 +105,32 @@ export class PluginRequirementStateService {
|
|||||||
return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId));
|
return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private readonly pluginRequirements = inject(PluginRequirementService);
|
||||||
|
|
||||||
|
private readonly pluginStore = inject(PluginStoreService);
|
||||||
|
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly registry = inject(PluginRegistryService);
|
||||||
|
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
|
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||||
|
|
||||||
|
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
||||||
|
|
||||||
|
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||||
|
|
||||||
|
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.realtime.onSignalingMessage
|
this.realtime.onSignalingMessage
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -268,6 +284,7 @@ export class PluginRequirementStateService {
|
|||||||
|
|
||||||
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
|
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRequirementDismissals(): RequirementDismissalState {
|
function loadRequirementDismissals(): RequirementDismissalState {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -70,52 +69,27 @@ interface ServerInstalledPluginsLoadState {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginStoreService {
|
export class PluginStoreService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
|
||||||
private readonly desktopState = inject(PluginDesktopStateService);
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly host = inject(PluginHostService);
|
|
||||||
private readonly jsonStorage = jsonStorage;
|
|
||||||
private readonly pluginRequirements = inject(PluginRequirementService);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
|
|
||||||
private readonly registry = inject(PluginRegistryService);
|
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
|
|
||||||
private readonly store = inject(Store, { optional: true });
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null;
|
|
||||||
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
|
|
||||||
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
|
|
||||||
private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null;
|
|
||||||
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
|
|
||||||
private readonly sourceUrlsSignal = signal<string[]>([]);
|
|
||||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
|
||||||
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
|
||||||
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
|
||||||
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
|
|
||||||
actorUserId: null,
|
|
||||||
loaded: false,
|
|
||||||
loading: false,
|
|
||||||
roomId: null
|
|
||||||
});
|
|
||||||
private readonly loadingSignal = signal(false);
|
|
||||||
private refreshAbortController: AbortController | null = null;
|
|
||||||
private refreshVersion = 0;
|
|
||||||
private installedLoadVersion = 0;
|
|
||||||
private autoUpdateInProgress = false;
|
|
||||||
private stateMutated = false;
|
|
||||||
|
|
||||||
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
||||||
|
|
||||||
readonly sources = this.sourcesSignal.asReadonly();
|
readonly sources = this.sourcesSignal.asReadonly();
|
||||||
|
|
||||||
readonly installedPlugins = computed(() => {
|
readonly installedPlugins = computed(() => {
|
||||||
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
|
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
|
||||||
|
|
||||||
return installedPlugins.sort(sortInstalledPlugins);
|
return installedPlugins.sort(sortInstalledPlugins);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isLoading = this.loadingSignal.asReadonly();
|
readonly isLoading = this.loadingSignal.asReadonly();
|
||||||
|
|
||||||
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
|
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
|
||||||
|
|
||||||
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
|
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
|
||||||
|
|
||||||
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
|
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
|
||||||
|
|
||||||
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
|
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
|
||||||
|
|
||||||
readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => {
|
readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => {
|
||||||
const roomId = this.currentRoomId?.() ?? null;
|
const roomId = this.currentRoomId?.() ?? null;
|
||||||
const actorUserId = this.currentActorUserId();
|
const actorUserId = this.currentActorUserId();
|
||||||
@@ -132,6 +106,67 @@ export class PluginStoreService {
|
|||||||
&& loadState.actorUserId === actorUserId;
|
&& loadState.actorUserId === actorUserId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
|
|
||||||
|
private readonly desktopState = inject(PluginDesktopStateService);
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private readonly host = inject(PluginHostService);
|
||||||
|
|
||||||
|
private readonly jsonStorage = jsonStorage;
|
||||||
|
|
||||||
|
private readonly pluginRequirements = inject(PluginRequirementService);
|
||||||
|
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
|
||||||
|
|
||||||
|
private readonly registry = inject(PluginRegistryService);
|
||||||
|
|
||||||
|
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
|
||||||
|
|
||||||
|
private readonly store = inject(Store, { optional: true });
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null;
|
||||||
|
|
||||||
|
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
|
||||||
|
|
||||||
|
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
|
||||||
|
|
||||||
|
private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null;
|
||||||
|
|
||||||
|
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
|
||||||
|
|
||||||
|
private readonly sourceUrlsSignal = signal<string[]>([]);
|
||||||
|
|
||||||
|
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
||||||
|
|
||||||
|
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||||
|
|
||||||
|
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
|
||||||
|
|
||||||
|
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
|
||||||
|
actorUserId: null,
|
||||||
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
roomId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly loadingSignal = signal(false);
|
||||||
|
|
||||||
|
private refreshAbortController: AbortController | null = null;
|
||||||
|
|
||||||
|
private refreshVersion = 0;
|
||||||
|
|
||||||
|
private installedLoadVersion = 0;
|
||||||
|
|
||||||
|
private autoUpdateInProgress = false;
|
||||||
|
|
||||||
|
private stateMutated = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const state = this.loadState();
|
const state = this.loadState();
|
||||||
|
|
||||||
@@ -243,73 +278,6 @@ export class PluginStoreService {
|
|||||||
return installedPlugin;
|
return installedPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null {
|
|
||||||
if (installScope !== 'server') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null;
|
|
||||||
|
|
||||||
if (!targetServerId) {
|
|
||||||
throw new Error('Open a chat server before installing server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetServerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistInstallResult(
|
|
||||||
installScope: TojuPluginInstallScope,
|
|
||||||
targetServerId: string | null,
|
|
||||||
nextInstalledPlugins: InstalledStorePlugin[],
|
|
||||||
installedPlugin: InstalledStorePlugin,
|
|
||||||
options: PluginStoreInstallOptions
|
|
||||||
): Promise<void> {
|
|
||||||
if (installScope === 'server') {
|
|
||||||
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async registerInstallResult(
|
|
||||||
installScope: TojuPluginInstallScope,
|
|
||||||
targetServerId: string | null,
|
|
||||||
nextInstalledPlugins: InstalledStorePlugin[],
|
|
||||||
installedPlugin: InstalledStorePlugin,
|
|
||||||
options: PluginStoreInstallOptions
|
|
||||||
): Promise<void> {
|
|
||||||
if (installScope === 'server' && targetServerId) {
|
|
||||||
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installScope === 'server' && options.activate) {
|
|
||||||
this.registry.setEnabled(installedPlugin.manifest.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
|
||||||
if (options.activate) {
|
|
||||||
await this.host.rememberActivation(installedPlugin.manifest.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl;
|
|
||||||
|
|
||||||
if (sourcePath?.startsWith('file://')) {
|
|
||||||
await this.ensurePluginSourceReadRoot(sourcePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, sourcePath);
|
|
||||||
|
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
|
||||||
|
|
||||||
if (options.activate) {
|
|
||||||
await this.host.activatePluginById(installedPlugin.manifest.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
||||||
if (!plugin.installUrl) {
|
if (!plugin.installUrl) {
|
||||||
throw new Error('Plugin does not provide an install manifest URL');
|
throw new Error('Plugin does not provide an install manifest URL');
|
||||||
@@ -478,6 +446,73 @@ export class PluginStoreService {
|
|||||||
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope();
|
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null {
|
||||||
|
if (installScope !== 'server') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null;
|
||||||
|
|
||||||
|
if (!targetServerId) {
|
||||||
|
throw new Error('Open a chat server before installing server-scoped plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistInstallResult(
|
||||||
|
installScope: TojuPluginInstallScope,
|
||||||
|
targetServerId: string | null,
|
||||||
|
nextInstalledPlugins: InstalledStorePlugin[],
|
||||||
|
installedPlugin: InstalledStorePlugin,
|
||||||
|
options: PluginStoreInstallOptions
|
||||||
|
): Promise<void> {
|
||||||
|
if (installScope === 'server') {
|
||||||
|
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerInstallResult(
|
||||||
|
installScope: TojuPluginInstallScope,
|
||||||
|
targetServerId: string | null,
|
||||||
|
nextInstalledPlugins: InstalledStorePlugin[],
|
||||||
|
installedPlugin: InstalledStorePlugin,
|
||||||
|
options: PluginStoreInstallOptions
|
||||||
|
): Promise<void> {
|
||||||
|
if (installScope === 'server' && targetServerId) {
|
||||||
|
await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installScope === 'server' && options.activate) {
|
||||||
|
this.registry.setEnabled(installedPlugin.manifest.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
||||||
|
if (options.activate) {
|
||||||
|
await this.host.rememberActivation(installedPlugin.manifest.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl;
|
||||||
|
|
||||||
|
if (sourcePath?.startsWith('file://')) {
|
||||||
|
await this.ensurePluginSourceReadRoot(sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.registerLocalManifest(installedPlugin.manifest, sourcePath);
|
||||||
|
|
||||||
|
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||||
|
|
||||||
|
if (options.activate) {
|
||||||
|
await this.host.activatePluginById(installedPlugin.manifest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
|
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
|
||||||
try {
|
try {
|
||||||
const sourceValue = await this.fetchJson(sourceUrl, signal);
|
const sourceValue = await this.fetchJson(sourceUrl, signal);
|
||||||
@@ -1068,6 +1103,7 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {
|
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import {
|
|||||||
expect,
|
expect,
|
||||||
it
|
it
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import {
|
import { collectPluginReadRoots, fileUrlToPath } from './plugin-local-file.rules';
|
||||||
collectPluginReadRoots,
|
|
||||||
fileUrlToPath,
|
|
||||||
pluginFileParentDir
|
|
||||||
} from './plugin-local-file.rules';
|
|
||||||
|
|
||||||
describe('plugin-local-file.rules', () => {
|
describe('plugin-local-file.rules', () => {
|
||||||
it('resolves linux file URLs to absolute paths', () => {
|
it('resolves linux file URLs to absolute paths', () => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
@@ -33,14 +32,17 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
export class PluginActionMenuComponent {
|
export class PluginActionMenuComponent {
|
||||||
readonly closed = output<undefined>();
|
readonly closed = output<undefined>();
|
||||||
|
|
||||||
private readonly logger = inject(PluginLoggerService);
|
|
||||||
private readonly pluginApi = inject(PluginClientApiService);
|
|
||||||
private readonly pluginRegistry = inject(PluginRegistryService);
|
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
|
|
||||||
readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
|
readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
|
||||||
.sort((left, right) => this.sortActionRecords(left, right)));
|
.sort((left, right) => this.sortActionRecords(left, right)));
|
||||||
|
|
||||||
|
private readonly logger = inject(PluginLoggerService);
|
||||||
|
|
||||||
|
private readonly pluginApi = inject(PluginClientApiService);
|
||||||
|
|
||||||
|
private readonly pluginRegistry = inject(PluginRegistryService);
|
||||||
|
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
close(): void {
|
close(): void {
|
||||||
this.closed.emit(undefined);
|
this.closed.emit(undefined);
|
||||||
@@ -95,6 +97,7 @@ export class PluginActionMenuComponent {
|
|||||||
|
|
||||||
return left.contribution.label.localeCompare(right.contribution.label);
|
return left.contribution.label.localeCompare(right.contribution.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitials(pluginName: string, actionLabel: string): string {
|
function createInitials(pluginName: string, actionLabel: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
|
||||||
<section
|
<section
|
||||||
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
|
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
|
||||||
data-testid="plugin-manager"
|
data-testid="plugin-manager"
|
||||||
|
|||||||
@@ -63,46 +63,64 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
|||||||
})
|
})
|
||||||
export class PluginManagerComponent {
|
export class PluginManagerComponent {
|
||||||
@Output() readonly closed = new EventEmitter<void>();
|
@Output() readonly closed = new EventEmitter<void>();
|
||||||
|
|
||||||
@Output() readonly storeOpened = new EventEmitter<void>();
|
@Output() readonly storeOpened = new EventEmitter<void>();
|
||||||
|
|
||||||
readonly scope = input<TojuPluginInstallScope>('client');
|
readonly scope = input<TojuPluginInstallScope>('client');
|
||||||
|
|
||||||
readonly capabilities = inject(PluginCapabilityService);
|
readonly capabilities = inject(PluginCapabilityService);
|
||||||
|
|
||||||
readonly host = inject(PluginHostService);
|
readonly host = inject(PluginHostService);
|
||||||
|
|
||||||
readonly logger = inject(PluginLoggerService);
|
readonly logger = inject(PluginLoggerService);
|
||||||
|
|
||||||
readonly registry = inject(PluginRegistryService);
|
readonly registry = inject(PluginRegistryService);
|
||||||
|
|
||||||
readonly requirementState = inject(PluginRequirementStateService);
|
readonly requirementState = inject(PluginRequirementStateService);
|
||||||
|
|
||||||
readonly router = inject(Router);
|
readonly router = inject(Router);
|
||||||
|
|
||||||
readonly uiRegistry = inject(PluginUiRegistryService);
|
readonly uiRegistry = inject(PluginUiRegistryService);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly activeTab = signal<PluginManagerTab>('installed');
|
readonly activeTab = signal<PluginManagerTab>('installed');
|
||||||
|
|
||||||
readonly busyPluginId = signal<string | null>(null);
|
readonly busyPluginId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly busyAll = signal(false);
|
readonly busyAll = signal(false);
|
||||||
|
|
||||||
readonly selectedPluginId = signal<string | null>(null);
|
readonly selectedPluginId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly allEntries = this.registry.entries;
|
readonly allEntries = this.registry.entries;
|
||||||
|
|
||||||
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
|
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
|
||||||
|
|
||||||
readonly managerTitle = computed(() => this.scope() === 'server'
|
readonly managerTitle = computed(() => this.scope() === 'server'
|
||||||
? this.appI18n.instant('plugins.manager.serverTitle')
|
? this.appI18n.instant('plugins.manager.serverTitle')
|
||||||
: this.appI18n.instant('plugins.manager.clientTitle'));
|
: this.appI18n.instant('plugins.manager.clientTitle'));
|
||||||
|
|
||||||
readonly managerDescription = computed(() => this.scope() === 'server'
|
readonly managerDescription = computed(() => this.scope() === 'server'
|
||||||
? this.appI18n.instant('plugins.manager.serverDescription')
|
? this.appI18n.instant('plugins.manager.serverDescription')
|
||||||
: this.appI18n.instant('plugins.manager.clientDescription'));
|
: this.appI18n.instant('plugins.manager.clientDescription'));
|
||||||
|
|
||||||
readonly selectedPlugin = computed(() => {
|
readonly selectedPlugin = computed(() => {
|
||||||
const selectedPluginId = this.selectedPluginId();
|
const selectedPluginId = this.selectedPluginId();
|
||||||
|
|
||||||
return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null;
|
return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly missingCapabilities = computed(() => {
|
readonly missingCapabilities = computed(() => {
|
||||||
const selectedPlugin = this.selectedPlugin();
|
const selectedPlugin = this.selectedPlugin();
|
||||||
|
|
||||||
return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : [];
|
return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedLogs = computed(() => {
|
readonly selectedLogs = computed(() => {
|
||||||
const selectedPlugin = this.selectedPlugin();
|
const selectedPlugin = this.selectedPlugin();
|
||||||
|
|
||||||
return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id)
|
return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id)
|
||||||
.slice(-20) : [];
|
.slice(-20) : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly extensionCounts = computed(() => ({
|
readonly extensionCounts = computed(() => ({
|
||||||
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
@@ -114,6 +132,7 @@ export class PluginManagerComponent {
|
|||||||
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
||||||
}));
|
}));
|
||||||
|
|
||||||
readonly extensionCountItems = computed(() => {
|
readonly extensionCountItems = computed(() => {
|
||||||
const counts = this.extensionCounts();
|
const counts = this.extensionCounts();
|
||||||
|
|
||||||
@@ -129,15 +148,20 @@ export class PluginManagerComponent {
|
|||||||
{ label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds }
|
{ label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds }
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
||||||
|
|
||||||
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
|
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
|
||||||
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
|
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
|
||||||
|
|
||||||
readonly selectedRequirement = computed(() => {
|
readonly selectedRequirement = computed(() => {
|
||||||
const selectedPlugin = this.selectedPlugin();
|
const selectedPlugin = this.selectedPlugin();
|
||||||
|
|
||||||
return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null;
|
return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null);
|
readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null);
|
||||||
|
|
||||||
readonly selectedSettingsPages = computed(() => {
|
readonly selectedSettingsPages = computed(() => {
|
||||||
const selectedPlugin = this.selectedPlugin();
|
const selectedPlugin = this.selectedPlugin();
|
||||||
|
|
||||||
@@ -145,12 +169,15 @@ export class PluginManagerComponent {
|
|||||||
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
|
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly emptyTitle = computed(() => this.scope() === 'server'
|
readonly emptyTitle = computed(() => this.scope() === 'server'
|
||||||
? this.appI18n.instant('plugins.manager.empty.serverTitle')
|
? this.appI18n.instant('plugins.manager.empty.serverTitle')
|
||||||
: this.appI18n.instant('plugins.manager.empty.clientTitle'));
|
: this.appI18n.instant('plugins.manager.empty.clientTitle'));
|
||||||
|
|
||||||
readonly emptyBody = computed(() => this.scope() === 'server'
|
readonly emptyBody = computed(() => this.scope() === 'server'
|
||||||
? this.appI18n.instant('plugins.manager.empty.serverBody')
|
? this.appI18n.instant('plugins.manager.empty.serverBody')
|
||||||
: this.appI18n.instant('plugins.manager.empty.clientBody'));
|
: this.appI18n.instant('plugins.manager.empty.clientBody'));
|
||||||
|
|
||||||
readonly selectedDocs = computed(() => {
|
readonly selectedDocs = computed(() => {
|
||||||
const manifest = this.selectedPlugin()?.manifest;
|
const manifest = this.selectedPlugin()?.manifest;
|
||||||
|
|
||||||
@@ -166,6 +193,8 @@ export class PluginManagerComponent {
|
|||||||
].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0);
|
].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
setTab(tab: PluginManagerTab): void {
|
setTab(tab: PluginManagerTab): void {
|
||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
}
|
}
|
||||||
@@ -265,4 +294,5 @@ export class PluginManagerComponent {
|
|||||||
private hasVisiblePlugin(pluginId: string): boolean {
|
private hasVisiblePlugin(pluginId: string): boolean {
|
||||||
return this.entries().some((entry) => entry.manifest.id === pluginId);
|
return this.entries().some((entry) => entry.manifest.id === pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||||
<main
|
<main
|
||||||
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
|
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
|
||||||
data-testid="plugin-store-page"
|
data-testid="plugin-store-page"
|
||||||
|
|||||||
@@ -93,12 +93,17 @@ interface ServerPluginInstallDialog {
|
|||||||
})
|
})
|
||||||
export class PluginStoreComponent implements OnInit {
|
export class PluginStoreComponent implements OnInit {
|
||||||
readonly store = inject(PluginStoreService);
|
readonly store = inject(PluginStoreService);
|
||||||
|
|
||||||
readonly capabilities = inject(PluginCapabilityService);
|
readonly capabilities = inject(PluginCapabilityService);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly ngrxStore = inject(NgRxStore);
|
readonly ngrxStore = inject(NgRxStore);
|
||||||
|
|
||||||
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
|
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
|
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
|
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly manageableServers = computed(() => {
|
readonly manageableServers = computed(() => {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
@@ -116,8 +121,11 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
return Array.from(roomsById.values())
|
return Array.from(roomsById.values())
|
||||||
.filter((room) => this.canManageServerPlugins(room, user));
|
.filter((room) => this.canManageServerPlugins(room, user));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
|
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
|
||||||
|
|
||||||
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
||||||
|
|
||||||
readonly filteredPlugins = computed(() => {
|
readonly filteredPlugins = computed(() => {
|
||||||
const searchTerm = this.debouncedSearchTerm().trim()
|
const searchTerm = this.debouncedSearchTerm().trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -134,19 +142,25 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
|
|
||||||
return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm));
|
return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly installedCount = computed(() => this.store.installedPlugins().length);
|
readonly installedCount = computed(() => this.store.installedPlugins().length);
|
||||||
|
|
||||||
readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length);
|
readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length);
|
||||||
|
|
||||||
readonly sourceCount = computed(() => this.store.sourceUrls().length);
|
readonly sourceCount = computed(() => this.store.sourceUrls().length);
|
||||||
|
|
||||||
readonly pendingSourceUrls = computed(() => {
|
readonly pendingSourceUrls = computed(() => {
|
||||||
const loadedUrls = new Set(this.store.sources().map((source) => source.url));
|
const loadedUrls = new Set(this.store.sources().map((source) => source.url));
|
||||||
|
|
||||||
return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl));
|
return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedReadmePlugin = computed(() => {
|
readonly selectedReadmePlugin = computed(() => {
|
||||||
const readme = this.readme();
|
const readme = this.readme();
|
||||||
|
|
||||||
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
|
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedStoreServer = computed(() => {
|
readonly selectedStoreServer = computed(() => {
|
||||||
const selectedServerId = this.selectedStoreServerId();
|
const selectedServerId = this.selectedStoreServerId();
|
||||||
|
|
||||||
@@ -154,23 +168,41 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
newSourceUrl = '';
|
newSourceUrl = '';
|
||||||
|
|
||||||
readonly searchTerm = signal('');
|
readonly searchTerm = signal('');
|
||||||
|
|
||||||
readonly selectedSourceUrl = signal<string | null>(null);
|
readonly selectedSourceUrl = signal<string | null>(null);
|
||||||
|
|
||||||
readonly selectedStoreServerId = signal<string | null>(null);
|
readonly selectedStoreServerId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
|
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
|
||||||
|
|
||||||
readonly showInstalledOnly = signal(false);
|
readonly showInstalledOnly = signal(false);
|
||||||
|
|
||||||
readonly sourceError = signal<string | null>(null);
|
readonly sourceError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly actionError = signal<string | null>(null);
|
readonly actionError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly actionBusyPluginId = signal<string | null>(null);
|
readonly actionBusyPluginId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||||
|
|
||||||
readonly readmeRawMode = signal(false);
|
readonly readmeRawMode = signal(false);
|
||||||
|
|
||||||
readonly readmeError = signal<string | null>(null);
|
readonly readmeError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly readmeLoadingPluginId = signal<string | null>(null);
|
readonly readmeLoadingPluginId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
||||||
|
|
||||||
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
|
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
|
||||||
|
|
||||||
readonly serverInstallOptional = signal(false);
|
readonly serverInstallOptional = signal(false);
|
||||||
|
|
||||||
readonly serverInstallError = signal<string | null>(null);
|
readonly serverInstallError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly serverInstallBusy = signal(false);
|
readonly serverInstallBusy = signal(false);
|
||||||
|
|
||||||
readonly brokenImageKeys = signal<Set<string>>(new Set());
|
readonly brokenImageKeys = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,12 +215,20 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
{ initialValue: '' }
|
{ initialValue: '' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
private readonly externalLinks = inject(ExternalLinkService);
|
private readonly externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
|
||||||
private selectedServerLoadVersion = 0;
|
private selectedServerLoadVersion = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -644,6 +684,7 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function comparePluginVersions(leftVersion: string, rightVersion: string): number {
|
function comparePluginVersions(leftVersion: string, rightVersion: string): number {
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import type { LocalPluginDiscoveryResult, LocalPluginManifestDescriptor } from '
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LocalPluginDiscoveryService {
|
export class LocalPluginDiscoveryService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
|
|
||||||
get isAvailable(): boolean {
|
get isAvailable(): boolean {
|
||||||
return this.electronBridge.isAvailable;
|
return this.electronBridge.isAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
async getPluginsPath(): Promise<string | null> {
|
async getPluginsPath(): Promise<string | null> {
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
@@ -43,4 +44,5 @@ export class LocalPluginDiscoveryService {
|
|||||||
pluginsPath: result.pluginsPath
|
pluginsPath: result.pluginsPath
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,21 +31,27 @@ import {
|
|||||||
templateUrl: './profile-avatar-editor.component.html'
|
templateUrl: './profile-avatar-editor.component.html'
|
||||||
})
|
})
|
||||||
export class ProfileAvatarEditorComponent {
|
export class ProfileAvatarEditorComponent {
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private readonly avatar = inject(ProfileAvatarFacade);
|
|
||||||
|
|
||||||
readonly source = input.required<EditableProfileAvatarSource>();
|
readonly source = input.required<EditableProfileAvatarSource>();
|
||||||
|
|
||||||
readonly cancelled = output<undefined>();
|
readonly cancelled = output<undefined>();
|
||||||
|
|
||||||
readonly confirmed = output<ProcessedProfileAvatar>();
|
readonly confirmed = output<ProcessedProfileAvatar>();
|
||||||
|
|
||||||
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
|
||||||
|
|
||||||
readonly processing = signal(false);
|
readonly processing = signal(false);
|
||||||
|
|
||||||
readonly errorMessage = signal<string | null>(null);
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
|
readonly preservesAnimation = computed(() => this.source().preservesAnimation);
|
||||||
|
|
||||||
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
|
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 0 });
|
offsetY: 0 });
|
||||||
|
|
||||||
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
|
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
|
||||||
|
|
||||||
readonly imageTransform = computed(() => {
|
readonly imageTransform = computed(() => {
|
||||||
const source = this.source();
|
const source = this.source();
|
||||||
const transform = this.clampedTransform();
|
const transform = this.clampedTransform();
|
||||||
@@ -54,7 +60,12 @@ export class ProfileAvatarEditorComponent {
|
|||||||
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
|
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly avatar = inject(ProfileAvatarFacade);
|
||||||
|
|
||||||
private dragPointerId: number | null = null;
|
private dragPointerId: number | null = null;
|
||||||
|
|
||||||
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
@@ -161,4 +172,5 @@ export class ProfileAvatarEditorComponent {
|
|||||||
this.processing.set(false);
|
this.processing.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -51,51 +50,32 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
|||||||
export class ScreenShareViewerComponent implements OnDestroy {
|
export class ScreenShareViewerComponent implements OnDestroy {
|
||||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
private readonly screenShareService = inject(ScreenShareFacade);
|
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private remoteStreamSub: Subscription | null = null;
|
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
activeScreenSharer = signal<User | null>(null);
|
activeScreenSharer = signal<User | null>(null);
|
||||||
// Track the userId we're currently watching (for detecting when they stop sharing)
|
|
||||||
private watchingUserId = signal<string | null>(null);
|
|
||||||
isFullscreen = signal(false);
|
isFullscreen = signal(false);
|
||||||
|
|
||||||
hasStream = signal(false);
|
hasStream = signal(false);
|
||||||
|
|
||||||
isLocalShare = signal(false);
|
isLocalShare = signal(false);
|
||||||
|
|
||||||
screenVolume = signal(DEFAULT_VOLUME);
|
screenVolume = signal(DEFAULT_VOLUME);
|
||||||
|
|
||||||
|
private readonly screenShareService = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private remoteStreamSub: Subscription | null = null;
|
||||||
|
|
||||||
|
// Track the userId we're currently watching (for detecting when they stop sharing)
|
||||||
|
private watchingUserId = signal<string | null>(null);
|
||||||
|
|
||||||
private streamSubscription: (() => void) | null = null;
|
private streamSubscription: (() => void) | null = null;
|
||||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
|
||||||
try {
|
|
||||||
const userId = evt.detail?.userId;
|
|
||||||
|
|
||||||
if (!userId)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
|
|
||||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
|
||||||
|
|
||||||
if (stream && stream.getVideoTracks().length > 0) {
|
|
||||||
if (user) {
|
|
||||||
this.setRemoteStream(stream, user);
|
|
||||||
} else if (this.videoRef) {
|
|
||||||
this.videoRef.nativeElement.srcObject = stream;
|
|
||||||
this.videoRef.nativeElement.volume = 0;
|
|
||||||
this.videoRef.nativeElement.muted = true;
|
|
||||||
this.hasStream.set(true);
|
|
||||||
this.activeScreenSharer.set(null);
|
|
||||||
this.watchingUserId.set(userId);
|
|
||||||
this.screenVolume.set(this.voicePlayback.getUserVolume(userId));
|
|
||||||
this.isLocalShare.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// Failed to focus viewer on user stream
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// React to screen share stream changes
|
// React to screen share stream changes
|
||||||
@@ -292,4 +272,34 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||||
|
try {
|
||||||
|
const userId = evt.detail?.userId;
|
||||||
|
|
||||||
|
if (!userId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
|
||||||
|
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
||||||
|
|
||||||
|
if (stream && stream.getVideoTracks().length > 0) {
|
||||||
|
if (user) {
|
||||||
|
this.setRemoteStream(stream, user);
|
||||||
|
} else if (this.videoRef) {
|
||||||
|
this.videoRef.nativeElement.srcObject = stream;
|
||||||
|
this.videoRef.nativeElement.volume = 0;
|
||||||
|
this.videoRef.nativeElement.muted = true;
|
||||||
|
this.hasStream.set(true);
|
||||||
|
this.activeScreenSharer.set(null);
|
||||||
|
this.watchingUserId.set(userId);
|
||||||
|
this.screenVolume.set(this.voicePlayback.getUserVolume(userId));
|
||||||
|
this.isLocalShare.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to focus viewer on user stream
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ServerDirectoryService } from '../services/server-directory.service';
|
import { ServerDirectoryService } from '../services/server-directory.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ServerDirectoryFacade {
|
export class ServerDirectoryFacade {
|
||||||
private readonly service = inject(ServerDirectoryService);
|
|
||||||
|
|
||||||
readonly servers = this.service.servers;
|
readonly servers = this.service.servers;
|
||||||
|
|
||||||
readonly activeServers = this.service.activeServers;
|
readonly activeServers = this.service.activeServers;
|
||||||
|
|
||||||
readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers;
|
readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers;
|
||||||
|
|
||||||
readonly activeServer = this.service.activeServer;
|
readonly activeServer = this.service.activeServer;
|
||||||
|
|
||||||
|
private readonly service = inject(ServerDirectoryService);
|
||||||
|
|
||||||
awaitInitialServerHealthCheck(
|
awaitInitialServerHealthCheck(
|
||||||
...args: Parameters<ServerDirectoryService['awaitInitialServerHealthCheck']>
|
...args: Parameters<ServerDirectoryService['awaitInitialServerHealthCheck']>
|
||||||
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
|
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
|
||||||
@@ -238,4 +241,5 @@ export class ServerDirectoryFacade {
|
|||||||
): ReturnType<ServerDirectoryService['sendHeartbeat']> {
|
): ReturnType<ServerDirectoryService['sendHeartbeat']> {
|
||||||
return this.service.sendHeartbeat(...args);
|
return this.service.sendHeartbeat(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
@@ -50,34 +49,51 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class CreateServerDialogComponent {
|
export class CreateServerDialogComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
|
|
||||||
readonly isMobile = inject(ViewportService).isMobile;
|
readonly isMobile = inject(ViewportService).isMobile;
|
||||||
|
|
||||||
readonly created = output<undefined>();
|
readonly created = output<undefined>();
|
||||||
|
|
||||||
readonly cancelled = output<undefined>();
|
readonly cancelled = output<undefined>();
|
||||||
|
|
||||||
readonly categories = CATEGORY_PRESETS;
|
readonly categories = CATEGORY_PRESETS;
|
||||||
|
|
||||||
activeEndpoints = this.serverDirectory.activeServers;
|
activeEndpoints = this.serverDirectory.activeServers;
|
||||||
|
|
||||||
name = signal('');
|
name = signal('');
|
||||||
|
|
||||||
description = signal('');
|
description = signal('');
|
||||||
|
|
||||||
topic = signal('');
|
topic = signal('');
|
||||||
|
|
||||||
selectedCategoryId = signal<string | null>(null);
|
selectedCategoryId = signal<string | null>(null);
|
||||||
|
|
||||||
isPrivate = signal(false);
|
isPrivate = signal(false);
|
||||||
|
|
||||||
password = signal('');
|
password = signal('');
|
||||||
|
|
||||||
sourceId = signal('');
|
sourceId = signal('');
|
||||||
|
|
||||||
showAdvanced = signal(false);
|
showAdvanced = signal(false);
|
||||||
|
|
||||||
|
/** True when the form has enough to create a server. */
|
||||||
|
get canCreate(): boolean {
|
||||||
|
return this.name().trim().length > 0 && this.sourceId().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sourceId.set(this.activeEndpoints()[0]?.id ?? '');
|
this.sourceId.set(this.activeEndpoints()[0]?.id ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the form has enough to create a server. */
|
@HostListener('document:keydown.escape')
|
||||||
get canCreate(): boolean {
|
cancel(): void {
|
||||||
return this.name().trim().length > 0 && this.sourceId().length > 0;
|
this.cancelled.emit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCategory(category: ServerCategoryPreset): void {
|
selectCategory(category: ServerCategoryPreset): void {
|
||||||
@@ -95,11 +111,6 @@ export class CreateServerDialogComponent {
|
|||||||
this.showAdvanced.update((shown) => !shown);
|
this.showAdvanced.update((shown) => !shown);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
|
||||||
cancel(): void {
|
|
||||||
this.cancelled.emit(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
if (!this.canCreate) {
|
if (!this.canCreate) {
|
||||||
return;
|
return;
|
||||||
@@ -128,4 +139,5 @@ export class CreateServerDialogComponent {
|
|||||||
|
|
||||||
this.created.emit(undefined);
|
this.created.emit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -59,32 +58,44 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
|
|||||||
templateUrl: './create-server.component.html'
|
templateUrl: './create-server.component.html'
|
||||||
})
|
})
|
||||||
export class CreateServerComponent implements OnInit {
|
export class CreateServerComponent implements OnInit {
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
|
|
||||||
readonly categories = CATEGORY_PRESETS;
|
readonly categories = CATEGORY_PRESETS;
|
||||||
|
|
||||||
activeEndpoints = this.serverDirectory.activeServers;
|
activeEndpoints = this.serverDirectory.activeServers;
|
||||||
|
|
||||||
name = signal('');
|
name = signal('');
|
||||||
description = signal('');
|
|
||||||
topic = signal('');
|
|
||||||
selectedCategoryId = signal<string | null>(null);
|
|
||||||
isPrivate = signal(false);
|
|
||||||
password = signal('');
|
|
||||||
sourceId = '';
|
|
||||||
showAdvanced = signal(false);
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
description = signal('');
|
||||||
this.sourceId = this.activeEndpoints()[0]?.id ?? '';
|
|
||||||
}
|
topic = signal('');
|
||||||
|
|
||||||
|
selectedCategoryId = signal<string | null>(null);
|
||||||
|
|
||||||
|
isPrivate = signal(false);
|
||||||
|
|
||||||
|
password = signal('');
|
||||||
|
|
||||||
|
sourceId = '';
|
||||||
|
|
||||||
|
showAdvanced = signal(false);
|
||||||
|
|
||||||
/** True when the form has enough to create a server. */
|
/** True when the form has enough to create a server. */
|
||||||
get canCreate(): boolean {
|
get canCreate(): boolean {
|
||||||
return this.name().trim().length > 0 && this.sourceId.length > 0;
|
return this.name().trim().length > 0 && this.sourceId.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
selectCategory(category: ServerCategoryPreset): void {
|
selectCategory(category: ServerCategoryPreset): void {
|
||||||
if (this.selectedCategoryId() === category.id) {
|
if (this.selectedCategoryId() === category.id) {
|
||||||
this.selectedCategoryId.set(null);
|
this.selectedCategoryId.set(null);
|
||||||
@@ -133,4 +144,5 @@ export class CreateServerComponent implements OnInit {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -44,15 +43,16 @@ const RECENT_SERVER_LIMIT = 6;
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class FindServersComponent implements OnInit {
|
export class FindServersComponent implements OnInit {
|
||||||
private store = inject(Store);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
featured = signal<ServerInfo[]>([]);
|
featured = signal<ServerInfo[]>([]);
|
||||||
|
|
||||||
trending = signal<ServerInfo[]>([]);
|
trending = signal<ServerInfo[]>([]);
|
||||||
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder');
|
readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder');
|
||||||
|
|
||||||
readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle');
|
readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle');
|
||||||
|
|
||||||
readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage');
|
readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage');
|
||||||
|
|
||||||
/** Discovery sections shown when the user is not actively searching. */
|
/** Discovery sections shown when the user is not actively searching. */
|
||||||
@@ -95,6 +95,12 @@ export class FindServersComponent implements OnInit {
|
|||||||
/** True when there is nothing to recommend (a brand-new account). */
|
/** True when there is nothing to recommend (a brand-new account). */
|
||||||
isNewUser = computed(() => this.discoverySections().length === 0);
|
isNewUser = computed(() => this.discoverySections().length === 0);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers));
|
this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers));
|
||||||
this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers));
|
this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers));
|
||||||
@@ -120,4 +126,5 @@ export class FindServersComponent implements OnInit {
|
|||||||
sourceUrl: room.sourceUrl
|
sourceUrl: room.sourceUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
effect,
|
effect,
|
||||||
@@ -115,28 +114,19 @@ export interface ServerDiscoverySection {
|
|||||||
templateUrl: './server-browser.component.html'
|
templateUrl: './server-browser.component.html'
|
||||||
})
|
})
|
||||||
export class ServerBrowserComponent implements OnInit {
|
export class ServerBrowserComponent implements OnInit {
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private db = inject(DatabaseService);
|
|
||||||
private externalLinks = inject(ExternalLinkService);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private pluginRequirements = inject(PluginRequirementService);
|
|
||||||
private pluginStore = inject(PluginStoreService);
|
|
||||||
private injector = inject(Injector);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
|
||||||
private searchSubject = new Subject<string>();
|
|
||||||
private banLookupRequestVersion = 0;
|
|
||||||
|
|
||||||
/** Discovery sections shown when the search query is empty. */
|
/** Discovery sections shown when the search query is empty. */
|
||||||
@Input() discoverySections: ServerDiscoverySection[] = [];
|
@Input() discoverySections: ServerDiscoverySection[] = [];
|
||||||
|
|
||||||
/** Title for the onboarding empty state when there is nothing to show. */
|
/** Title for the onboarding empty state when there is nothing to show. */
|
||||||
@Input() emptyStateTitle?: string;
|
@Input() emptyStateTitle?: string;
|
||||||
|
|
||||||
/** Supporting copy for the onboarding empty state. */
|
/** Supporting copy for the onboarding empty state. */
|
||||||
@Input() emptyStateMessage?: string;
|
@Input() emptyStateMessage?: string;
|
||||||
|
|
||||||
/** Placeholder for the search input. */
|
/** Placeholder for the search input. */
|
||||||
@Input() searchPlaceholder?: string;
|
@Input() searchPlaceholder?: string;
|
||||||
|
|
||||||
/** Whether the My Servers quick bar is shown. */
|
/** Whether the My Servers quick bar is shown. */
|
||||||
@Input() showMyServers = true;
|
@Input() showMyServers = true;
|
||||||
|
|
||||||
@@ -152,6 +142,95 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder');
|
return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchQuery = '';
|
||||||
|
|
||||||
|
searchResults = this.store.selectSignal(selectSearchResults);
|
||||||
|
|
||||||
|
isSearching = this.store.selectSignal(selectIsSearching);
|
||||||
|
|
||||||
|
error = this.store.selectSignal(selectRoomsError);
|
||||||
|
|
||||||
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
activeEndpoints = this.serverDirectory.activeServers;
|
||||||
|
|
||||||
|
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
bannedServerName = signal('');
|
||||||
|
|
||||||
|
showBannedDialog = signal(false);
|
||||||
|
|
||||||
|
showPasswordDialog = signal(false);
|
||||||
|
|
||||||
|
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||||
|
|
||||||
|
joinPassword = signal('');
|
||||||
|
|
||||||
|
joinPasswordError = signal<string | null>(null);
|
||||||
|
|
||||||
|
joinErrorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
joinedServerMenuId = signal<string | null>(null);
|
||||||
|
|
||||||
|
leaveDialogRoom = signal<Room | null>(null);
|
||||||
|
|
||||||
|
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
||||||
|
|
||||||
|
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
||||||
|
|
||||||
|
pluginConsentBusy = signal(false);
|
||||||
|
|
||||||
|
pluginConsentError = signal<string | null>(null);
|
||||||
|
|
||||||
|
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
||||||
|
|
||||||
|
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
||||||
|
|
||||||
|
pluginConsentReadmeError = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** True while the user is actively searching (non-empty query). */
|
||||||
|
get isSearchMode(): boolean {
|
||||||
|
return this.searchQuery.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery sections that actually contain servers. */
|
||||||
|
get visibleSections(): ServerDiscoverySection[] {
|
||||||
|
return this.discoverySections.filter((section) => section.servers.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when there is nothing to render outside of search mode. */
|
||||||
|
get showEmptyState(): boolean {
|
||||||
|
return !this.isSearchMode && this.visibleSections.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
|
||||||
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private pluginRequirements = inject(PluginRequirementService);
|
||||||
|
|
||||||
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
|
||||||
|
private injector = inject(Injector);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||||
|
|
||||||
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
serverCardTitle(server: ServerInfo): string {
|
serverCardTitle(server: ServerInfo): string {
|
||||||
return this.isJoinedServer(server)
|
return this.isJoinedServer(server)
|
||||||
? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name })
|
? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name })
|
||||||
@@ -200,31 +279,6 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
: this.i18n.instant('servers.plugins.readme');
|
: this.i18n.instant('servers.plugins.readme');
|
||||||
}
|
}
|
||||||
|
|
||||||
searchQuery = '';
|
|
||||||
searchResults = this.store.selectSignal(selectSearchResults);
|
|
||||||
isSearching = this.store.selectSignal(selectIsSearching);
|
|
||||||
error = this.store.selectSignal(selectRoomsError);
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
activeEndpoints = this.serverDirectory.activeServers;
|
|
||||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
|
||||||
bannedServerName = signal('');
|
|
||||||
showBannedDialog = signal(false);
|
|
||||||
showPasswordDialog = signal(false);
|
|
||||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
|
||||||
joinPassword = signal('');
|
|
||||||
joinPasswordError = signal<string | null>(null);
|
|
||||||
joinErrorMessage = signal<string | null>(null);
|
|
||||||
joinedServerMenuId = signal<string | null>(null);
|
|
||||||
leaveDialogRoom = signal<Room | null>(null);
|
|
||||||
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
|
||||||
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
|
||||||
pluginConsentBusy = signal(false);
|
|
||||||
pluginConsentError = signal<string | null>(null);
|
|
||||||
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
|
||||||
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
|
||||||
pluginConsentReadmeError = signal<string | null>(null);
|
|
||||||
|
|
||||||
// The reactive effect is created in ngOnInit with an explicit injector so the
|
// The reactive effect is created in ngOnInit with an explicit injector so the
|
||||||
// component can be instantiated outside a change-detection context (e.g. unit tests).
|
// component can be instantiated outside a change-detection context (e.g. unit tests).
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -247,21 +301,6 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True while the user is actively searching (non-empty query). */
|
|
||||||
get isSearchMode(): boolean {
|
|
||||||
return this.searchQuery.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discovery sections that actually contain servers. */
|
|
||||||
get visibleSections(): ServerDiscoverySection[] {
|
|
||||||
return this.discoverySections.filter((section) => section.servers.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when there is nothing to render outside of search mode. */
|
|
||||||
get showEmptyState(): boolean {
|
|
||||||
return !this.isSearchMode && this.visibleSections.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearchChange(query: string): void {
|
onSearchChange(query: string): void {
|
||||||
this.searchSubject.next(query);
|
this.searchSubject.next(query);
|
||||||
}
|
}
|
||||||
@@ -724,4 +763,5 @@ export class ServerBrowserComponent implements OnInit {
|
|||||||
|
|
||||||
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,28 +49,45 @@ type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
|||||||
})
|
})
|
||||||
export class ThemeSettingsComponent {
|
export class ThemeSettingsComponent {
|
||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
|
|
||||||
readonly theme = inject(ThemeService);
|
readonly theme = inject(ThemeService);
|
||||||
|
|
||||||
readonly themeLibrary = inject(ThemeLibraryService);
|
readonly themeLibrary = inject(ThemeLibraryService);
|
||||||
|
|
||||||
readonly registry = inject(ThemeRegistryService);
|
readonly registry = inject(ThemeRegistryService);
|
||||||
|
|
||||||
readonly picker = inject(ElementPickerService);
|
readonly picker = inject(ElementPickerService);
|
||||||
|
|
||||||
readonly layoutSync = inject(LayoutSyncService);
|
readonly layoutSync = inject(LayoutSyncService);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
|
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
|
||||||
|
|
||||||
readonly draftText = this.theme.draftText;
|
readonly draftText = this.theme.draftText;
|
||||||
|
|
||||||
readonly draftErrors = this.theme.draftErrors;
|
readonly draftErrors = this.theme.draftErrors;
|
||||||
|
|
||||||
readonly draftIsValid = this.theme.draftIsValid;
|
readonly draftIsValid = this.theme.draftIsValid;
|
||||||
|
|
||||||
readonly statusMessage = this.theme.statusMessage;
|
readonly statusMessage = this.theme.statusMessage;
|
||||||
|
|
||||||
readonly isDraftDirty = this.theme.isDraftDirty;
|
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||||
|
|
||||||
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||||
|
|
||||||
readonly activeTheme = this.theme.activeTheme;
|
readonly activeTheme = this.theme.activeTheme;
|
||||||
|
|
||||||
readonly builtInPresets = this.theme.builtInPresets;
|
readonly builtInPresets = this.theme.builtInPresets;
|
||||||
|
|
||||||
readonly draftTheme = this.theme.draftTheme;
|
readonly draftTheme = this.theme.draftTheme;
|
||||||
|
|
||||||
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||||
|
|
||||||
readonly animationKeys = this.theme.knownAnimationClasses;
|
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||||
|
|
||||||
readonly layoutContainers = this.layoutSync.containers();
|
readonly layoutContainers = this.layoutSync.containers();
|
||||||
|
|
||||||
readonly themeEntries = this.registry.entries();
|
readonly themeEntries = this.registry.entries();
|
||||||
|
|
||||||
readonly workspaceTabs = computed(() => [
|
readonly workspaceTabs = computed(() => [
|
||||||
{
|
{
|
||||||
key: 'editor' as const,
|
key: 'editor' as const,
|
||||||
@@ -88,15 +105,23 @@ export class ThemeSettingsComponent {
|
|||||||
description: this.appI18n.instant('theme.studio.workspaces.layout.description')
|
description: this.appI18n.instant('theme.studio.workspaces.layout.description')
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||||
|
|
||||||
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||||
|
|
||||||
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
readonly activeEditorTab = signal<ThemeEditorTab>('json');
|
||||||
|
|
||||||
readonly cssOnlyText = signal('');
|
readonly cssOnlyText = signal('');
|
||||||
|
|
||||||
readonly explorerQuery = signal('');
|
readonly explorerQuery = signal('');
|
||||||
|
|
||||||
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||||
|
|
||||||
readonly selectedElementKey = signal<string>('chatRoomMainPanel');
|
readonly selectedElementKey = signal<string>('chatRoomMainPanel');
|
||||||
|
|
||||||
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
|
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
|
||||||
|
|
||||||
readonly selectedElementCapabilities = computed(() => {
|
readonly selectedElementCapabilities = computed(() => {
|
||||||
const selected = this.selectedElement();
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
@@ -111,24 +136,31 @@ export class ThemeSettingsComponent {
|
|||||||
selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null
|
selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null
|
||||||
].filter((value): value is string => value !== null);
|
].filter((value): value is string => value !== null);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||||
|
|
||||||
readonly selectedLayoutContainer = computed(() => {
|
readonly selectedLayoutContainer = computed(() => {
|
||||||
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedElementGrid = computed(() => {
|
readonly selectedElementGrid = computed(() => {
|
||||||
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly activeWorkspaceInfo = computed(() => {
|
readonly activeWorkspaceInfo = computed(() => {
|
||||||
return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0];
|
return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly localizedFilteredEntries = computed(() =>
|
readonly localizedFilteredEntries = computed(() =>
|
||||||
this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry))
|
this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry))
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly localizedSelectedElement = computed(() => {
|
readonly localizedSelectedElement = computed(() => {
|
||||||
const selected = this.selectedElement();
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
return selected ? this.localizeRegistryEntry(selected) : null;
|
return selected ? this.localizeRegistryEntry(selected) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly visiblePropertyHints = computed(() => {
|
readonly visiblePropertyHints = computed(() => {
|
||||||
const selected = this.selectedElement();
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
@@ -148,9 +180,11 @@ export class ThemeSettingsComponent {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly mountedEntries = computed(() => {
|
readonly mountedEntries = computed(() => {
|
||||||
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly filteredEntries = computed(() => {
|
readonly filteredEntries = computed(() => {
|
||||||
const query = this.explorerQuery().trim()
|
const query = this.explorerQuery().trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -165,17 +199,29 @@ export class ThemeSettingsComponent {
|
|||||||
return haystack.includes(query);
|
return haystack.includes(query);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly draftLineCount = computed(() => this.draftText().split('\n').length);
|
readonly draftLineCount = computed(() => this.draftText().split('\n').length);
|
||||||
|
|
||||||
readonly draftCharacterCount = computed(() => this.draftText().length);
|
readonly draftCharacterCount = computed(() => this.draftText().length);
|
||||||
|
|
||||||
readonly draftErrorCount = computed(() => this.draftErrors().length);
|
readonly draftErrorCount = computed(() => this.draftErrors().length);
|
||||||
|
|
||||||
readonly mountedEntryCount = computed(() => this.mountedEntries().length);
|
readonly mountedEntryCount = computed(() => this.mountedEntries().length);
|
||||||
|
|
||||||
readonly llmGuideCopyMessage = signal<string | null>(null);
|
readonly llmGuideCopyMessage = signal<string | null>(null);
|
||||||
|
|
||||||
readonly savedThemesAvailable = this.themeLibrary.isAvailable;
|
readonly savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||||
|
|
||||||
readonly savedThemes = this.themeLibrary.entries;
|
readonly savedThemes = this.themeLibrary.entries;
|
||||||
|
|
||||||
readonly savedThemesBusy = this.themeLibrary.isBusy;
|
readonly savedThemesBusy = this.themeLibrary.isBusy;
|
||||||
|
|
||||||
readonly savedThemesPath = this.themeLibrary.savedThemesPath;
|
readonly savedThemesPath = this.themeLibrary.savedThemesPath;
|
||||||
|
|
||||||
readonly selectedSavedTheme = this.themeLibrary.selectedEntry;
|
readonly selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -485,6 +531,26 @@ export class ThemeSettingsComponent {
|
|||||||
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
presetDisplayName(presetKey: string, fallbackName: string): string {
|
||||||
|
const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`);
|
||||||
|
|
||||||
|
return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
presetDescription(presetKey: string, fallbackDescription?: string): string | undefined {
|
||||||
|
if (!fallbackDescription) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`);
|
||||||
|
|
||||||
|
return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDefaultPresetName(name: string): boolean {
|
||||||
|
return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name');
|
||||||
|
}
|
||||||
|
|
||||||
private focusEditor(): void {
|
private focusEditor(): void {
|
||||||
this.withEditorReady((editor) => {
|
this.withEditorReady((editor) => {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
@@ -815,26 +881,6 @@ export class ThemeSettingsComponent {
|
|||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
presetDisplayName(presetKey: string, fallbackName: string): string {
|
|
||||||
const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`);
|
|
||||||
|
|
||||||
return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized;
|
|
||||||
}
|
|
||||||
|
|
||||||
presetDescription(presetKey: string, fallbackDescription?: string): string | undefined {
|
|
||||||
if (!fallbackDescription) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`);
|
|
||||||
|
|
||||||
return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized;
|
|
||||||
}
|
|
||||||
|
|
||||||
isDefaultPresetName(name: string): boolean {
|
|
||||||
return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name');
|
|
||||||
}
|
|
||||||
|
|
||||||
private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry {
|
private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
@@ -853,4 +899,5 @@ export class ThemeSettingsComponent {
|
|||||||
|
|
||||||
return text.indexOf(`"${key}"`, sectionIndex);
|
return text.indexOf(`"${key}"`, sectionIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T>
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ThemeLibraryStorageService {
|
export class ThemeLibraryStorageService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
|
|
||||||
get isAvailable(): boolean {
|
get isAvailable(): boolean {
|
||||||
return this.electronBridge.isAvailable;
|
return this.electronBridge.isAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
async getSavedThemesPath(): Promise<string | null> {
|
async getSavedThemesPath(): Promise<string | null> {
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
@@ -218,4 +219,5 @@ export class ThemeLibraryStorageService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import {
|
|||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||||
import { DebuggingService } from '../../../../core/services/debugging.service';
|
import { DebuggingService } from '../../../../core/services/debugging.service';
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
|
||||||
|
|
||||||
const SPEAKING_THRESHOLD = 0.015;
|
const SPEAKING_THRESHOLD = 0.015;
|
||||||
const SILENT_FRAME_GRACE = 8;
|
const SILENT_FRAME_GRACE = 8;
|
||||||
const FFT_SIZE = 256;
|
const FFT_SIZE = 256;
|
||||||
@@ -46,15 +44,20 @@ interface TrackedStream {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class VoiceActivityService implements OnDestroy {
|
export class VoiceActivityService implements OnDestroy {
|
||||||
|
|
||||||
|
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
||||||
|
|
||||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
private readonly debugging = inject(DebuggingService);
|
private readonly debugging = inject(DebuggingService);
|
||||||
|
|
||||||
private readonly tracked = new Map<string, TrackedStream>();
|
private readonly tracked = new Map<string, TrackedStream>();
|
||||||
private animFrameId: number | null = null;
|
|
||||||
private readonly subs: Subscription[] = [];
|
|
||||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
|
||||||
|
|
||||||
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
private animFrameId: number | null = null;
|
||||||
|
|
||||||
|
private readonly subs: Subscription[] = [];
|
||||||
|
|
||||||
|
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
@@ -176,30 +179,11 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAllRemoteStreamsTracked(): void {
|
ngOnDestroy(): void {
|
||||||
const peers = this.voiceConnection.getConnectedPeers();
|
this.stopPolling();
|
||||||
|
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
||||||
for (const peerId of peers) {
|
this.tracked.clear();
|
||||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
this.subs.forEach((subscription) => subscription.unsubscribe());
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
this.trackStream(peerId, stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensurePolling(): void {
|
|
||||||
if (this.animFrameId !== null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPolling(): void {
|
|
||||||
if (this.animFrameId !== null) {
|
|
||||||
cancelAnimationFrame(this.animFrameId);
|
|
||||||
this.animFrameId = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private poll = (): void => {
|
private poll = (): void => {
|
||||||
@@ -212,8 +196,8 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
let sumSquares = 0;
|
let sumSquares = 0;
|
||||||
|
|
||||||
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
for (const sample of dataArray) {
|
||||||
const normalised = (dataArray[sampleIndex] - 128) / 128;
|
const normalised = (sample - 128) / 128;
|
||||||
|
|
||||||
sumSquares += normalised * normalised;
|
sumSquares += normalised * normalised;
|
||||||
}
|
}
|
||||||
@@ -249,6 +233,32 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.animFrameId = requestAnimationFrame(this.poll);
|
this.animFrameId = requestAnimationFrame(this.poll);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private ensureAllRemoteStreamsTracked(): void {
|
||||||
|
const peers = this.voiceConnection.getConnectedPeers();
|
||||||
|
|
||||||
|
for (const peerId of peers) {
|
||||||
|
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
this.trackStream(peerId, stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensurePolling(): void {
|
||||||
|
if (this.animFrameId !== null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.animFrameId !== null) {
|
||||||
|
cancelAnimationFrame(this.animFrameId);
|
||||||
|
this.animFrameId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private publishSpeakingMap(): void {
|
private publishSpeakingMap(): void {
|
||||||
const map = new Map<string, boolean>();
|
const map = new Map<string, boolean>();
|
||||||
|
|
||||||
@@ -269,16 +279,18 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
private disposeEntry(entry: TrackedStream): void {
|
private disposeEntry(entry: TrackedStream): void {
|
||||||
entry.sources.forEach((source) => {
|
entry.sources.forEach((source) => {
|
||||||
try { source.disconnect(); } catch { /* already disconnected */ }
|
try {
|
||||||
|
source.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try { entry.ctx.close(); } catch { /* already closed */ }
|
try {
|
||||||
|
entry.ctx.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.stopPolling();
|
|
||||||
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
|
||||||
this.tracked.clear();
|
|
||||||
this.subs.forEach((subscription) => subscription.unsubscribe());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
signal,
|
signal,
|
||||||
@@ -20,13 +19,6 @@ import type { VoiceSessionInfo } from '../../domain/models/voice-session.model';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class VoiceSessionFacade {
|
export class VoiceSessionFacade {
|
||||||
private readonly store = inject(Store);
|
|
||||||
|
|
||||||
/** Current voice session metadata, or `null` when disconnected. */
|
|
||||||
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
|
||||||
|
|
||||||
/** Whether the user is currently viewing the voice-connected server. */
|
|
||||||
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
|
||||||
|
|
||||||
/** Reactive read-only voice session. */
|
/** Reactive read-only voice session. */
|
||||||
readonly voiceSession = computed(() => this._voiceSession());
|
readonly voiceSession = computed(() => this._voiceSession());
|
||||||
@@ -43,6 +35,14 @@ export class VoiceSessionFacade {
|
|||||||
() => this._voiceSession() !== null && !this._isViewingVoiceServer()
|
() => this._voiceSession() !== null && !this._isViewingVoiceServer()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
/** Current voice session metadata, or `null` when disconnected. */
|
||||||
|
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
||||||
|
|
||||||
|
/** Whether the user is currently viewing the voice-connected server. */
|
||||||
|
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin tracking a voice session.
|
* Begin tracking a voice session.
|
||||||
* Called when the user joins a voice channel.
|
* Called when the user joins a voice channel.
|
||||||
@@ -111,4 +111,5 @@ export class VoiceSessionFacade {
|
|||||||
getVoiceServerId(): string | null {
|
getVoiceServerId(): string | null {
|
||||||
return this._voiceSession()?.serverId ?? null;
|
return this._voiceSession()?.serverId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -23,15 +22,6 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class VoiceWorkspaceService {
|
export class VoiceWorkspaceService {
|
||||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
|
||||||
|
|
||||||
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
|
|
||||||
private readonly _focusedStreamId = signal<string | null>(null);
|
|
||||||
private readonly _connectRemoteShares = signal(false);
|
|
||||||
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
|
||||||
DEFAULT_MINI_WINDOW_POSITION
|
|
||||||
);
|
|
||||||
private readonly _hasCustomMiniWindowPosition = signal(false);
|
|
||||||
|
|
||||||
readonly mode = computed<VoiceWorkspaceMode>(() => {
|
readonly mode = computed<VoiceWorkspaceMode>(() => {
|
||||||
if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) {
|
if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) {
|
||||||
@@ -42,15 +32,35 @@ export class VoiceWorkspaceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly isExpanded = computed(() => this.mode() === 'expanded');
|
readonly isExpanded = computed(() => this.mode() === 'expanded');
|
||||||
|
|
||||||
readonly isMinimized = computed(() => this.mode() === 'minimized');
|
readonly isMinimized = computed(() => this.mode() === 'minimized');
|
||||||
|
|
||||||
readonly isVisible = computed(() => this.mode() !== 'hidden');
|
readonly isVisible = computed(() => this.mode() !== 'hidden');
|
||||||
|
|
||||||
readonly focusedStreamId = computed(() => this._focusedStreamId());
|
readonly focusedStreamId = computed(() => this._focusedStreamId());
|
||||||
|
|
||||||
readonly shouldConnectRemoteShares = computed(
|
readonly shouldConnectRemoteShares = computed(
|
||||||
() => this.isVisible() && this._connectRemoteShares()
|
() => this.isVisible() && this._connectRemoteShares()
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly miniWindowPosition = computed(() => this._miniWindowPosition());
|
readonly miniWindowPosition = computed(() => this._miniWindowPosition());
|
||||||
|
|
||||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||||
|
|
||||||
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
|
||||||
|
|
||||||
|
private readonly _focusedStreamId = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly _connectRemoteShares = signal(false);
|
||||||
|
|
||||||
|
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
||||||
|
DEFAULT_MINI_WINDOW_POSITION
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly _hasCustomMiniWindowPosition = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.voiceSession.voiceSession()) {
|
if (this.voiceSession.voiceSession()) {
|
||||||
@@ -125,4 +135,5 @@ export class VoiceWorkspaceService {
|
|||||||
this._connectRemoteShares.set(false);
|
this._connectRemoteShares.set(false);
|
||||||
this.resetMiniWindowPosition();
|
this.resetMiniWindowPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -60,30 +59,45 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
|||||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||||
*/
|
*/
|
||||||
export class FloatingVoiceControlsComponent implements OnInit {
|
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;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
// Voice state from services
|
// Voice state from services
|
||||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||||
|
|
||||||
voiceSession = this.voiceSessionService.voiceSession;
|
voiceSession = this.voiceSessionService.voiceSession;
|
||||||
|
|
||||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||||
|
|
||||||
isMuted = signal(false);
|
isMuted = signal(false);
|
||||||
|
|
||||||
isDeafened = signal(false);
|
isDeafened = signal(false);
|
||||||
|
|
||||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||||
|
|
||||||
includeSystemAudio = signal(false);
|
includeSystemAudio = signal(false);
|
||||||
|
|
||||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
|
|
||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
|
|
||||||
showScreenShareQualityDialog = signal(false);
|
showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
|
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private readonly screenShareService = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Sync mute/deafen state from webrtc service
|
// Sync mute/deafen state from webrtc service
|
||||||
@@ -300,8 +314,9 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
quality
|
quality
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch {
|
||||||
// Screen share request was denied or failed
|
// Screen share request was denied or failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
@@ -75,23 +74,15 @@ interface AudioDevice {
|
|||||||
templateUrl: './voice-controls.component.html'
|
templateUrl: './voice-controls.component.html'
|
||||||
})
|
})
|
||||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
|
||||||
private readonly screenShareService = inject(ScreenShareFacade);
|
|
||||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
|
||||||
private readonly hostEl = inject(ElementRef);
|
|
||||||
private readonly profileCard = inject(ProfileCardService);
|
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||||
|
|
||||||
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
|
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
|
||||||
|
|
||||||
connectionErrorMessage = computed(() => {
|
connectionErrorMessage = computed(() => {
|
||||||
const message = this.webrtcService.connectionErrorMessage();
|
const message = this.webrtcService.connectionErrorMessage();
|
||||||
|
|
||||||
@@ -101,12 +92,65 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
return this.appI18n.instant(message);
|
return this.appI18n.instant(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
isMuted = signal(false);
|
isMuted = signal(false);
|
||||||
|
|
||||||
isDeafened = signal(false);
|
isDeafened = signal(false);
|
||||||
|
|
||||||
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
||||||
|
|
||||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||||
|
|
||||||
showSettings = signal(false);
|
showSettings = signal(false);
|
||||||
|
|
||||||
|
inputDevices = signal<AudioDevice[]>([]);
|
||||||
|
|
||||||
|
outputDevices = signal<AudioDevice[]>([]);
|
||||||
|
|
||||||
|
selectedInputDevice = signal<string>('');
|
||||||
|
|
||||||
|
selectedOutputDevice = signal<string>('');
|
||||||
|
|
||||||
|
inputVolume = signal(100);
|
||||||
|
|
||||||
|
outputVolume = signal(100);
|
||||||
|
|
||||||
|
audioBitrate = signal(96);
|
||||||
|
|
||||||
|
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||||
|
|
||||||
|
includeSystemAudio = signal(false);
|
||||||
|
|
||||||
|
noiseReduction = signal(true);
|
||||||
|
|
||||||
|
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
|
|
||||||
|
askScreenShareQuality = signal(true);
|
||||||
|
|
||||||
|
showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
|
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private readonly screenShareService = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
|
||||||
|
private readonly hostEl = inject(ElementRef);
|
||||||
|
|
||||||
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
toggleProfileCard(): void {
|
toggleProfileCard(): void {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
@@ -116,27 +160,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
inputDevices = signal<AudioDevice[]>([]);
|
|
||||||
outputDevices = signal<AudioDevice[]>([]);
|
|
||||||
selectedInputDevice = signal<string>('');
|
|
||||||
selectedOutputDevice = signal<string>('');
|
|
||||||
inputVolume = signal(100);
|
|
||||||
outputVolume = signal(100);
|
|
||||||
audioBitrate = signal(96);
|
|
||||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
|
||||||
includeSystemAudio = signal(false);
|
|
||||||
noiseReduction = signal(true);
|
|
||||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
|
||||||
askScreenShareQuality = signal(true);
|
|
||||||
showScreenShareQualityDialog = signal(false);
|
|
||||||
|
|
||||||
private playbackOptions(): PlaybackOptions {
|
|
||||||
return {
|
|
||||||
isConnected: this.isConnected(),
|
|
||||||
outputVolume: this.outputVolume() / 100,
|
|
||||||
isDeafened: this.isDeafened()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.loadAudioDevices();
|
await this.loadAudioDevices();
|
||||||
|
|
||||||
@@ -166,7 +189,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.outputDevices.set(
|
this.outputDevices.set(
|
||||||
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||||
);
|
);
|
||||||
} catch (_error) {}
|
} catch { /* ignore device enumeration errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -263,7 +286,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
async retryConnection(): Promise<void> {
|
async retryConnection(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.webrtcService.ensureSignalingConnected(10000);
|
await this.webrtcService.ensureSignalingConnected(10000);
|
||||||
} catch (_error) {}
|
} catch { /* ignore device enumeration errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
@@ -438,7 +461,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {}
|
} catch { /* ignore device enumeration errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
@@ -547,6 +570,58 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMuteButtonClass(): string {
|
||||||
|
const base =
|
||||||
|
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
if (this.isMuted()) {
|
||||||
|
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeafenButtonClass(): string {
|
||||||
|
const base =
|
||||||
|
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
if (this.isDeafened()) {
|
||||||
|
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCameraButtonClass(): string {
|
||||||
|
const base =
|
||||||
|
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
if (this.isCameraEnabled()) {
|
||||||
|
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScreenShareButtonClass(): string {
|
||||||
|
const base =
|
||||||
|
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
if (this.isScreenSharing()) {
|
||||||
|
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private playbackOptions(): PlaybackOptions {
|
||||||
|
return {
|
||||||
|
isConnected: this.isConnected(),
|
||||||
|
outputVolume: this.outputVolume() / 100,
|
||||||
|
isDeafened: this.isDeafened()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private loadSettings(): void {
|
private loadSettings(): void {
|
||||||
const settings = loadVoiceSettingsFromStorage();
|
const settings = loadVoiceSettingsFromStorage();
|
||||||
|
|
||||||
@@ -614,50 +689,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
quality
|
quality
|
||||||
});
|
});
|
||||||
} catch (_error) {}
|
} catch { /* ignore device enumeration errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
getMuteButtonClass(): string {
|
|
||||||
const base =
|
|
||||||
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
|
||||||
|
|
||||||
if (this.isMuted()) {
|
|
||||||
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDeafenButtonClass(): string {
|
|
||||||
const base =
|
|
||||||
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
|
||||||
|
|
||||||
if (this.isDeafened()) {
|
|
||||||
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCameraButtonClass(): string {
|
|
||||||
const base =
|
|
||||||
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
|
||||||
|
|
||||||
if (this.isCameraEnabled()) {
|
|
||||||
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getScreenShareButtonClass(): string {
|
|
||||||
const base =
|
|
||||||
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
|
||||||
|
|
||||||
if (this.isScreenSharing()) {
|
|
||||||
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ function schedulePersist(settings: VoiceSettings): void {
|
|||||||
timeRemaining(): number;
|
timeRemaining(): number;
|
||||||
}
|
}
|
||||||
type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
|
type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
|
||||||
interface MaybeIdleGlobal {
|
interface IdleCallbackGlobal {
|
||||||
requestIdleCallback?: IdleRequest;
|
requestIdleCallback?: IdleRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal;
|
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as IdleCallbackGlobal;
|
||||||
|
|
||||||
if (typeof idleGlobal.requestIdleCallback === 'function') {
|
if (typeof idleGlobal.requestIdleCallback === 'function') {
|
||||||
idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 });
|
idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 });
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
@@ -100,24 +99,22 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class DashboardComponent implements OnInit {
|
export class DashboardComponent implements OnInit {
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private friendsService = inject(FriendService);
|
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private searchSubject = new Subject<string>();
|
|
||||||
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
searchQuery = signal('');
|
searchQuery = signal('');
|
||||||
|
|
||||||
serverResults = this.store.selectSignal(selectSearchResults);
|
serverResults = this.store.selectSignal(selectSearchResults);
|
||||||
|
|
||||||
isSearching = this.store.selectSignal(selectIsSearching);
|
isSearching = this.store.selectSignal(selectIsSearching);
|
||||||
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
popularServers = signal<ServerInfo[]>([]);
|
popularServers = signal<ServerInfo[]>([]);
|
||||||
|
|
||||||
recentSearches = signal<string[]>(this.loadRecentSearches());
|
recentSearches = signal<string[]>(this.loadRecentSearches());
|
||||||
private users = this.store.selectSignal(selectAllUsers);
|
|
||||||
|
|
||||||
/** True while the user is actively typing a query. */
|
/** True while the user is actively typing a query. */
|
||||||
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
|
isSearchMode = computed(() => this.searchQuery().trim().length > 0);
|
||||||
@@ -125,37 +122,6 @@ export class DashboardComponent implements OnInit {
|
|||||||
/** Server matches limited for the quick-search list. */
|
/** Server matches limited for the quick-search list. */
|
||||||
topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT));
|
topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT));
|
||||||
|
|
||||||
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
|
|
||||||
private discoveredPeople = computed<User[]>(() => {
|
|
||||||
const currentKey = this.currentUserKey();
|
|
||||||
const byKey = new Map<string, User>();
|
|
||||||
|
|
||||||
for (const user of this.users()) {
|
|
||||||
byKey.set(user.oderId || user.id, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const room of this.savedRooms()) {
|
|
||||||
for (const member of room.members ?? []) {
|
|
||||||
const key = member.oderId || member.id;
|
|
||||||
|
|
||||||
if (byKey.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
byKey.set(key, {
|
|
||||||
id: member.id,
|
|
||||||
oderId: key,
|
|
||||||
username: member.username,
|
|
||||||
displayName: member.displayName,
|
|
||||||
avatarUrl: member.avatarUrl,
|
|
||||||
status: 'disconnected'
|
|
||||||
} as User);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** People matches derived from known users and saved-room members. */
|
/** People matches derived from known users and saved-room members. */
|
||||||
topPeopleResults = computed<User[]>(() => {
|
topPeopleResults = computed<User[]>(() => {
|
||||||
const query = this.searchQuery().trim()
|
const query = this.searchQuery().trim()
|
||||||
@@ -200,6 +166,63 @@ export class DashboardComponent implements OnInit {
|
|||||||
/** True for a brand-new account with no servers and no known people. */
|
/** True for a brand-new account with no servers and no known people. */
|
||||||
isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0);
|
isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private friendsService = inject(FriendService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private searchSubject = new Subject<string>();
|
||||||
|
|
||||||
|
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||||
|
|
||||||
|
private users = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
|
/** Every distinct person known to the account (known users plus saved-room members), excluding self. */
|
||||||
|
private discoveredPeople = computed<User[]>(() => {
|
||||||
|
const currentKey = this.currentUserKey();
|
||||||
|
const byKey = new Map<string, User>();
|
||||||
|
|
||||||
|
for (const user of this.users()) {
|
||||||
|
byKey.set(user.oderId || user.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const room of this.savedRooms()) {
|
||||||
|
for (const member of room.members ?? []) {
|
||||||
|
const key = member.oderId || member.id;
|
||||||
|
|
||||||
|
if (byKey.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
byKey.set(key, {
|
||||||
|
id: member.id,
|
||||||
|
oderId: key,
|
||||||
|
username: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
status: 'disconnected'
|
||||||
|
} as User);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
onGlobalKeydown(event: KeyboardEvent): void {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.searchInputRef()?.nativeElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
@@ -217,14 +240,6 @@ export class DashboardComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
|
||||||
onGlobalKeydown(event: KeyboardEvent): void {
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.searchInputRef()?.nativeElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearchChange(query: string): void {
|
onSearchChange(query: string): void {
|
||||||
this.searchQuery.set(query);
|
this.searchQuery.set(query);
|
||||||
this.searchSubject.next(query);
|
this.searchSubject.next(query);
|
||||||
@@ -357,4 +372,5 @@ export class DashboardComponent implements OnInit {
|
|||||||
.filter((value): value is string => typeof value === 'string')
|
.filter((value): value is string => typeof value === 'string')
|
||||||
.some((value) => value.toLowerCase().includes(query));
|
.some((value) => value.toLowerCase().includes(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Component,
|
Component,
|
||||||
@@ -82,34 +81,29 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
|
|||||||
templateUrl: './private-call.component.html'
|
templateUrl: './private-call.component.html'
|
||||||
})
|
})
|
||||||
export class PrivateCallComponent {
|
export class PrivateCallComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly calls = inject(DirectCallService);
|
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
|
||||||
private readonly voice = inject(VoiceConnectionFacade);
|
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
|
||||||
private readonly playback = inject(VoicePlaybackService);
|
|
||||||
private readonly screenShare = inject(ScreenShareFacade);
|
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
|
||||||
private chatResizing = false;
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
|
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
|
||||||
|
|
||||||
readonly speakerphoneEnabled = signal(true);
|
readonly speakerphoneEnabled = signal(true);
|
||||||
|
|
||||||
readonly callIdInput = input<string | null>(null);
|
readonly callIdInput = input<string | null>(null);
|
||||||
|
|
||||||
readonly overlayMode = input(false);
|
readonly overlayMode = input(false);
|
||||||
|
|
||||||
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
|
||||||
initialValue: this.route.snapshot.paramMap.get('callId')
|
initialValue: this.route.snapshot.paramMap.get('callId')
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
|
||||||
|
|
||||||
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
readonly session = computed(() => this.calls.sessionById(this.callId()));
|
||||||
|
|
||||||
readonly participantUsers = computed(() => {
|
readonly participantUsers = computed(() => {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
|
|
||||||
@@ -121,25 +115,40 @@ export class PrivateCallComponent {
|
|||||||
.map((participantId) => this.userForSessionParticipant(session, participantId))
|
.map((participantId) => this.userForSessionParticipant(session, participantId))
|
||||||
.filter((user): user is User => !!user);
|
.filter((user): user is User => !!user);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isConnected = computed(() => {
|
readonly isConnected = computed(() => {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
const currentUserId = this.currentUserKey();
|
const currentUserId = this.currentUserKey();
|
||||||
|
|
||||||
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
|
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly isMuted = this.voice.isMuted;
|
readonly isMuted = this.voice.isMuted;
|
||||||
|
|
||||||
readonly isDeafened = this.voice.isDeafened;
|
readonly isDeafened = this.voice.isDeafened;
|
||||||
|
|
||||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||||
|
|
||||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||||
|
|
||||||
readonly remoteStreamRevision = signal(0);
|
readonly remoteStreamRevision = signal(0);
|
||||||
|
|
||||||
readonly includeSystemAudio = signal(false);
|
readonly includeSystemAudio = signal(false);
|
||||||
|
|
||||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
|
|
||||||
readonly askScreenShareQuality = signal(true);
|
readonly askScreenShareQuality = signal(true);
|
||||||
|
|
||||||
readonly showScreenShareQualityDialog = signal(false);
|
readonly showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
readonly inviteUserId = signal('');
|
readonly inviteUserId = signal('');
|
||||||
|
|
||||||
readonly focusedStreamId = signal<string | null>(null);
|
readonly focusedStreamId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly showAllStreamsMode = signal(false);
|
readonly showAllStreamsMode = signal(false);
|
||||||
|
|
||||||
readonly chatWidthPx = signal(384);
|
readonly chatWidthPx = signal(384);
|
||||||
|
|
||||||
readonly inviteCandidates = computed(() => {
|
readonly inviteCandidates = computed(() => {
|
||||||
const participantIds = new Set(this.session()?.participantIds ?? []);
|
const participantIds = new Set(this.session()?.participantIds ?? []);
|
||||||
const currentUserId = this.currentUserKey();
|
const currentUserId = this.currentUserKey();
|
||||||
@@ -150,6 +159,7 @@ export class PrivateCallComponent {
|
|||||||
return userId !== currentUserId && !participantIds.has(userId);
|
return userId !== currentUserId && !participantIds.has(userId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
|
||||||
this.remoteStreamRevision();
|
this.remoteStreamRevision();
|
||||||
|
|
||||||
@@ -193,8 +203,11 @@ export class PrivateCallComponent {
|
|||||||
|
|
||||||
return shares;
|
return shares;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
|
readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
|
||||||
|
|
||||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||||
|
|
||||||
readonly focusedShareId = computed(() => {
|
readonly focusedShareId = computed(() => {
|
||||||
const requested = this.focusedStreamId();
|
const requested = this.focusedStreamId();
|
||||||
const activeShares = this.activeShares();
|
const activeShares = this.activeShares();
|
||||||
@@ -213,7 +226,9 @@ export class PrivateCallComponent {
|
|||||||
|
|
||||||
return null;
|
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(() => {
|
readonly thumbnailShares = computed(() => {
|
||||||
const focusedShareId = this.focusedShareId();
|
const focusedShareId = this.focusedShareId();
|
||||||
|
|
||||||
@@ -223,6 +238,37 @@ export class PrivateCallComponent {
|
|||||||
|
|
||||||
return this.activeShares().filter((share) => share.id !== focusedShareId);
|
return this.activeShares().filter((share) => share.id !== focusedShareId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly calls = inject(DirectCallService);
|
||||||
|
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private readonly voice = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
|
private readonly playback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly screenShare = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
|
||||||
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
|
||||||
|
private chatResizing = false;
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const callId = this.callId();
|
const callId = this.callId();
|
||||||
@@ -300,6 +346,8 @@ export class PrivateCallComponent {
|
|||||||
this.chatResizing = false;
|
this.chatResizing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
|
||||||
|
|
||||||
async join(): Promise<void> {
|
async join(): Promise<void> {
|
||||||
const session = this.session();
|
const session = this.session();
|
||||||
|
|
||||||
@@ -508,8 +556,6 @@ export class PrivateCallComponent {
|
|||||||
return user.oderId || user.id;
|
return user.oderId || user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
|
|
||||||
|
|
||||||
private currentUserKey(): string {
|
private currentUserKey(): string {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
@@ -659,4 +705,5 @@ export class PrivateCallComponent {
|
|||||||
private bumpRemoteStreamRevision(): void {
|
private bumpRemoteStreamRevision(): void {
|
||||||
this.remoteStreamRevision.update((value) => value + 1);
|
this.remoteStreamRevision.update((value) => value + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
Component,
|
Component,
|
||||||
@@ -97,28 +96,25 @@ interface SwiperElement extends HTMLElement {
|
|||||||
* remains the source of truth and stays in sync with the active slide.
|
* remains the source of truth and stays in sync with the active slide.
|
||||||
*/
|
*/
|
||||||
export class ChatRoomComponent {
|
export class ChatRoomComponent {
|
||||||
private readonly store = inject(Store);
|
|
||||||
private readonly 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);
|
showMenu = signal(false);
|
||||||
|
|
||||||
showAdminPanel = signal(false);
|
showAdminPanel = signal(false);
|
||||||
|
|
||||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||||
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
|
|
||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
|
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
|
||||||
* with the selected channel name instead of the room name.
|
* with the selected channel name instead of the room name.
|
||||||
@@ -132,19 +128,46 @@ export class ChatRoomComponent {
|
|||||||
|
|
||||||
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
|
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
|
|
||||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||||
|
|
||||||
activeCall = computed(() => {
|
activeCall = computed(() => {
|
||||||
const currentSession = this.directCalls.currentSession();
|
const currentSession = this.directCalls.currentSession();
|
||||||
const visibleSessions = this.directCalls.visibleActiveSessions();
|
const visibleSessions = this.directCalls.visibleActiveSessions();
|
||||||
|
|
||||||
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||||
|
|
||||||
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||||
|
|
||||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||||
|
|
||||||
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// When entering a server, always land on the channels list ("first page") on mobile, even
|
// 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
|
// if a default channel is pre-selected. Once inside the server, *changing* channels
|
||||||
@@ -237,5 +260,6 @@ export class ChatRoomComponent {
|
|||||||
this.settingsModal.open('server', room.id);
|
this.settingsModal.open('server', room.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||||
<div
|
<div
|
||||||
appThemeNode="roomPanelHeader"
|
appThemeNode="roomPanelHeader"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -141,52 +140,53 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
|||||||
templateUrl: './rooms-side-panel.component.html'
|
templateUrl: './rooms-side-panel.component.html'
|
||||||
})
|
})
|
||||||
export class RoomsSidePanelComponent implements OnDestroy {
|
export class RoomsSidePanelComponent implements OnDestroy {
|
||||||
private store = inject(Store);
|
|
||||||
private signalServerAuth = inject(SignalServerAuthService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private realtime = inject(RealtimeSessionFacade);
|
|
||||||
private voiceConnection = inject(VoiceConnectionFacade);
|
|
||||||
private screenShare = inject(ScreenShareFacade);
|
|
||||||
private notifications = inject(NotificationsFacade);
|
|
||||||
private voiceSessionService = inject(VoiceSessionFacade);
|
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private directCalls = inject(DirectCallService);
|
|
||||||
private profileCard = inject(ProfileCardService);
|
|
||||||
private directMessages = inject(DirectMessageService);
|
|
||||||
private readonly externalLinks = inject(ExternalLinkService);
|
|
||||||
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
|
||||||
private readonly voiceActivity = inject(VoiceActivityService);
|
|
||||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
|
||||||
private readonly pluginUi = inject(PluginUiRegistryService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
readonly panelMode = input<PanelMode>('channels');
|
readonly panelMode = input<PanelMode>('channels');
|
||||||
|
|
||||||
readonly showVoiceControls = input(true);
|
readonly showVoiceControls = input(true);
|
||||||
|
|
||||||
readonly textChannelSelected = output<string>();
|
readonly textChannelSelected = output<string>();
|
||||||
|
|
||||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||||
|
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
isConnecting = this.store.selectSignal(selectIsConnecting);
|
isConnecting = this.store.selectSignal(selectIsConnecting);
|
||||||
|
|
||||||
messagesLoading = this.store.selectSignal(selectMessagesLoading);
|
messagesLoading = this.store.selectSignal(selectMessagesLoading);
|
||||||
|
|
||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
|
|
||||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||||
|
|
||||||
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
pluginChannelSections = this.pluginUi.channelSectionRecords;
|
||||||
|
|
||||||
pluginMenuActions = this.pluginUi.toolbarActionRecords;
|
pluginMenuActions = this.pluginUi.toolbarActionRecords;
|
||||||
|
|
||||||
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
pluginSidePanels = this.pluginUi.sidePanelRecords;
|
||||||
|
|
||||||
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
|
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
|
||||||
|
|
||||||
delayedPanelHydrating = signal(false);
|
delayedPanelHydrating = signal(false);
|
||||||
|
|
||||||
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
|
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
|
||||||
|
|
||||||
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
|
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
|
||||||
|
|
||||||
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
|
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
|
||||||
|
|
||||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||||
|
|
||||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||||
|
|
||||||
roomMemberIdentifiers = computed(() => {
|
roomMemberIdentifiers = computed(() => {
|
||||||
const identifiers = new Set<string>();
|
const identifiers = new Set<string>();
|
||||||
|
|
||||||
@@ -196,6 +196,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
|
|
||||||
return identifiers;
|
return identifiers;
|
||||||
});
|
});
|
||||||
|
|
||||||
onlineRoomUsers = computed(() => {
|
onlineRoomUsers = computed(() => {
|
||||||
const memberIdentifiers = this.roomMemberIdentifiers();
|
const memberIdentifiers = this.roomMemberIdentifiers();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
@@ -204,6 +205,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
|
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
offlineRoomMembers = computed(() => {
|
offlineRoomMembers = computed(() => {
|
||||||
const onlineIdentifiers = new Set<string>();
|
const onlineIdentifiers = new Set<string>();
|
||||||
|
|
||||||
@@ -215,6 +217,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
|
|
||||||
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
|
||||||
});
|
});
|
||||||
|
|
||||||
knownUserCount = computed(() => {
|
knownUserCount = computed(() => {
|
||||||
const memberIds = new Set(
|
const memberIds = new Set(
|
||||||
this.roomMembers()
|
this.roomMembers()
|
||||||
@@ -230,36 +233,92 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
return memberIds.size;
|
return memberIds.size;
|
||||||
});
|
});
|
||||||
|
|
||||||
private hasPluginPanelContent(): boolean {
|
|
||||||
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
showChannelMenu = signal(false);
|
showChannelMenu = signal(false);
|
||||||
|
|
||||||
channelMenuX = signal(0);
|
channelMenuX = signal(0);
|
||||||
|
|
||||||
channelMenuY = signal(0);
|
channelMenuY = signal(0);
|
||||||
|
|
||||||
contextChannel = signal<Channel | null>(null);
|
contextChannel = signal<Channel | null>(null);
|
||||||
|
|
||||||
renamingChannelId = signal<string | null>(null);
|
renamingChannelId = signal<string | null>(null);
|
||||||
|
|
||||||
channelNameError = signal<string | null>(null);
|
channelNameError = signal<string | null>(null);
|
||||||
|
|
||||||
showCreateChannelDialog = signal(false);
|
showCreateChannelDialog = signal(false);
|
||||||
|
|
||||||
createChannelType = signal<'text' | 'voice'>('text');
|
createChannelType = signal<'text' | 'voice'>('text');
|
||||||
|
|
||||||
newChannelName = '';
|
newChannelName = '';
|
||||||
|
|
||||||
showUserMenu = signal(false);
|
showUserMenu = signal(false);
|
||||||
|
|
||||||
userMenuX = signal(0);
|
userMenuX = signal(0);
|
||||||
|
|
||||||
userMenuY = signal(0);
|
userMenuY = signal(0);
|
||||||
|
|
||||||
contextMenuUser = signal<User | null>(null);
|
contextMenuUser = signal<User | null>(null);
|
||||||
|
|
||||||
showVolumeMenu = signal(false);
|
showVolumeMenu = signal(false);
|
||||||
|
|
||||||
volumeMenuX = signal(0);
|
volumeMenuX = signal(0);
|
||||||
|
|
||||||
volumeMenuY = signal(0);
|
volumeMenuY = signal(0);
|
||||||
|
|
||||||
volumeMenuPeerId = signal('');
|
volumeMenuPeerId = signal('');
|
||||||
|
|
||||||
volumeMenuDisplayName = signal('');
|
volumeMenuDisplayName = signal('');
|
||||||
|
|
||||||
draggedVoiceUserId = signal<string | null>(null);
|
draggedVoiceUserId = signal<string | null>(null);
|
||||||
|
|
||||||
dragTargetVoiceChannelId = signal<string | null>(null);
|
dragTargetVoiceChannelId = signal<string | null>(null);
|
||||||
|
|
||||||
activityNow = signal(Date.now());
|
activityNow = signal(Date.now());
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private signalServerAuth = inject(SignalServerAuthService);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private realtime = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private voiceConnection = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private screenShare = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private notifications = inject(NotificationsFacade);
|
||||||
|
|
||||||
|
private voiceSessionService = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
|
|
||||||
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private directCalls = inject(DirectCallService);
|
||||||
|
|
||||||
|
private profileCard = inject(ProfileCardService);
|
||||||
|
|
||||||
|
private directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
|
private readonly externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
|
private readonly pluginActionMenu = inject(PluginActionMenuService);
|
||||||
|
|
||||||
|
private readonly voiceActivity = inject(VoiceActivityService);
|
||||||
|
|
||||||
|
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||||
|
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (!this.panelHydrating()) {
|
if (!this.panelHydrating()) {
|
||||||
@@ -292,15 +351,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
this.pluginActionMenu.close();
|
this.pluginActionMenu.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearSkeletonRevealTimer(): void {
|
|
||||||
if (!this.skeletonRevealTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(this.skeletonRevealTimer);
|
|
||||||
this.skeletonRevealTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
gameActivityElapsed(user: User | null | undefined): string {
|
gameActivityElapsed(user: User | null | undefined): string {
|
||||||
const activity = user?.gameActivity;
|
const activity = user?.gameActivity;
|
||||||
|
|
||||||
@@ -361,102 +411,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
await this.openDirectMessage(event, this.roomMemberToUser(member));
|
await this.openDirectMessage(event, this.roomMemberToUser(member));
|
||||||
}
|
}
|
||||||
|
|
||||||
private roomMemberToUser(member: RoomMember): User {
|
|
||||||
return {
|
|
||||||
id: member.id,
|
|
||||||
oderId: member.oderId || member.id,
|
|
||||||
username: member.username,
|
|
||||||
displayName: member.displayName,
|
|
||||||
description: member.description,
|
|
||||||
profileUpdatedAt: member.profileUpdatedAt,
|
|
||||||
avatarUrl: member.avatarUrl,
|
|
||||||
avatarHash: member.avatarHash,
|
|
||||||
avatarMime: member.avatarMime,
|
|
||||||
avatarUpdatedAt: member.avatarUpdatedAt,
|
|
||||||
status: 'disconnected',
|
|
||||||
role: member.role,
|
|
||||||
joinedAt: member.joinedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private roomMemberKey(member: RoomMember): string {
|
|
||||||
return member.oderId || member.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
|
||||||
if (!entity)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (entity.id) {
|
|
||||||
identifiers.add(entity.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entity.oderId) {
|
|
||||||
identifiers.add(entity.oderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
|
||||||
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
|
|
||||||
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entity.presenceServerIds.includes(roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
|
||||||
const current = this.currentUser();
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
|
||||||
current,
|
|
||||||
this.currentRoom()?.sourceUrl
|
|
||||||
);
|
|
||||||
|
|
||||||
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
|
||||||
const current = this.currentUser();
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addIdentifiers(identifiers, current);
|
|
||||||
|
|
||||||
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
|
||||||
current,
|
|
||||||
this.currentRoom()?.sourceUrl
|
|
||||||
)) {
|
|
||||||
identifiers.add(selfId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
|
||||||
this.cancelQueuedProfileCardOpen();
|
|
||||||
this.profileCardOpenTimer = setTimeout(() => {
|
|
||||||
this.profileCardOpenTimer = null;
|
|
||||||
this.profileCard.open(anchor, user, { placement: 'left', editable });
|
|
||||||
}, 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelQueuedProfileCardOpen(): void {
|
|
||||||
if (!this.profileCardOpenTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(this.profileCardOpenTimer);
|
|
||||||
this.profileCardOpenTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasConnectivityIssue(user: User): boolean {
|
hasConnectivityIssue(user: User): boolean {
|
||||||
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
||||||
}
|
}
|
||||||
@@ -643,25 +597,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
|
||||||
if (!name) {
|
|
||||||
return 'room.channel.nameRequired';
|
|
||||||
}
|
|
||||||
|
|
||||||
const channels = this.currentRoom()?.channels ?? [];
|
|
||||||
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
|
|
||||||
|
|
||||||
if (!channelType) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
|
|
||||||
return 'room.channel.nameUnique';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
@@ -722,21 +657,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
|
||||||
if (
|
|
||||||
!room
|
|
||||||
|| !current?.voiceState?.isConnected
|
|
||||||
|| current.voiceState.roomId !== roomId
|
|
||||||
|| current.voiceState.serverId !== room.id
|
|
||||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPassiveInVoiceRoom(roomId: string): boolean {
|
isPassiveInVoiceRoom(roomId: string): boolean {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -778,24 +698,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
||||||
}
|
}
|
||||||
|
|
||||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
|
||||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareVoiceJoin(room: Room, current: User | null): void {
|
|
||||||
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.disconnectCurrentVoiceTarget(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
|
||||||
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
|
|
||||||
|
|
||||||
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
joinVoice(roomId: string) {
|
joinVoice(roomId: string) {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
@@ -833,94 +735,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
startJoin();
|
startJoin();
|
||||||
}
|
}
|
||||||
|
|
||||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
|
||||||
this.voiceConnection.clearConnectionError();
|
|
||||||
this.updateVoiceStateStore(roomId, room, current);
|
|
||||||
this.trackCurrentUserMic();
|
|
||||||
this.startVoiceHeartbeat(roomId, room);
|
|
||||||
this.broadcastVoiceConnected(roomId, room, current);
|
|
||||||
this.startVoiceSession(roomId, room);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleVoiceJoinFailure(error: unknown): void {
|
|
||||||
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
|
||||||
|
|
||||||
this.voiceConnection.reportConnectionError(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackCurrentUserMic(): void {
|
|
||||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
|
||||||
const micStream = this.voiceConnection.getRawMicStream();
|
|
||||||
|
|
||||||
if (userId && micStream) {
|
|
||||||
this.voiceActivity.trackLocalMic(userId, micStream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private untrackCurrentUserMic(): void {
|
|
||||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
|
||||||
|
|
||||||
if (userId) {
|
|
||||||
this.voiceActivity.untrackLocalMic(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
|
||||||
if (!current?.id)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
|
||||||
roomId,
|
|
||||||
serverId: room.id,
|
|
||||||
clientInstanceId: this.realtime.getClientInstanceId()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
|
||||||
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
|
||||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
|
||||||
|
|
||||||
this.voiceConnection.broadcastMessage({
|
|
||||||
type: 'voice-state',
|
|
||||||
oderId: current?.oderId || current?.id,
|
|
||||||
displayName: current?.displayName || 'User',
|
|
||||||
voiceState: {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: current?.voiceState?.isMuted ?? false,
|
|
||||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
|
||||||
roomId,
|
|
||||||
serverId: room.id,
|
|
||||||
clientInstanceId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private startVoiceSession(roomId: string, room: Room): void {
|
|
||||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
|
||||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
|
||||||
|
|
||||||
this.voiceSessionService.startSession({
|
|
||||||
serverId: room.id,
|
|
||||||
serverName: room.name,
|
|
||||||
roomId,
|
|
||||||
roomName: voiceRoomName,
|
|
||||||
serverIcon: room.icon,
|
|
||||||
serverDescription: room.description,
|
|
||||||
serverRoute: `/room/${room.id}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveVoice(roomId: string) {
|
leaveVoice(roomId: string) {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
@@ -930,53 +744,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
this.disconnectCurrentVoiceTarget(current);
|
this.disconnectCurrentVoiceTarget(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
private disconnectCurrentVoiceTarget(current: User | null): void {
|
|
||||||
const previousVoiceState = current?.voiceState;
|
|
||||||
|
|
||||||
this.voiceConnection.stopVoiceHeartbeat();
|
|
||||||
this.untrackCurrentUserMic();
|
|
||||||
this.voiceConnection.disableVoice();
|
|
||||||
|
|
||||||
if (current?.id) {
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: current.id,
|
|
||||||
voiceState: {
|
|
||||||
isConnected: false,
|
|
||||||
isMuted: false,
|
|
||||||
isDeafened: false,
|
|
||||||
roomId: undefined,
|
|
||||||
serverId: undefined,
|
|
||||||
clientInstanceId: undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateCameraState({
|
|
||||||
userId: current.id,
|
|
||||||
cameraState: { isEnabled: false }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.voiceConnection.broadcastMessage({
|
|
||||||
type: 'voice-state',
|
|
||||||
oderId: current?.oderId || current?.id,
|
|
||||||
displayName: current?.displayName || 'User',
|
|
||||||
voiceState: {
|
|
||||||
isConnected: false,
|
|
||||||
isMuted: false,
|
|
||||||
isDeafened: false,
|
|
||||||
roomId: previousVoiceState?.roomId,
|
|
||||||
serverId: previousVoiceState?.serverId,
|
|
||||||
clientInstanceId: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.voiceSessionService.endSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
voiceOccupancy(roomId: string): number {
|
voiceOccupancy(roomId: string): number {
|
||||||
return this.voiceUsersInRoom(roomId).length;
|
return this.voiceUsersInRoom(roomId).length;
|
||||||
}
|
}
|
||||||
@@ -1059,47 +826,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
this.moveVoiceUserToChannel(draggedUserId, channelId);
|
this.moveVoiceUserToChannel(draggedUserId, channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
const actor = this.currentUser();
|
|
||||||
|
|
||||||
if (!room || !actor || !this.canMoveVoiceUsers()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
|
|
||||||
|
|
||||||
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const movedVoiceState: Partial<User['voiceState']> = {
|
|
||||||
isConnected: true,
|
|
||||||
isMuted: targetUser.voiceState.isMuted,
|
|
||||||
isDeafened: targetUser.voiceState.isDeafened,
|
|
||||||
isSpeaking: targetUser.voiceState.isSpeaking,
|
|
||||||
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
|
|
||||||
volume: targetUser.voiceState.volume,
|
|
||||||
roomId: channelId,
|
|
||||||
serverId: room.id
|
|
||||||
};
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.updateVoiceState({
|
|
||||||
userId: targetUser.id,
|
|
||||||
voiceState: movedVoiceState
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.realtime.broadcastMessage({
|
|
||||||
type: 'voice-channel-move',
|
|
||||||
roomId: room.id,
|
|
||||||
targetUserId: targetUser.oderId || targetUser.id,
|
|
||||||
voiceState: movedVoiceState,
|
|
||||||
displayName: targetUser.displayName
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isUserLocallyMuted(user: User): boolean {
|
isUserLocallyMuted(user: User): boolean {
|
||||||
const peerId = user.oderId || user.id;
|
const peerId = user.oderId || user.id;
|
||||||
|
|
||||||
@@ -1299,6 +1025,343 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
return this.appI18n.instant('room.panel.messageUser', { name: displayName });
|
return this.appI18n.instant('room.panel.messageUser', { name: displayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasPluginPanelContent(): boolean {
|
||||||
|
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSkeletonRevealTimer(): void {
|
||||||
|
if (!this.skeletonRevealTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.skeletonRevealTimer);
|
||||||
|
this.skeletonRevealTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomMemberToUser(member: RoomMember): User {
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
oderId: member.oderId || member.id,
|
||||||
|
username: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
description: member.description,
|
||||||
|
profileUpdatedAt: member.profileUpdatedAt,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
avatarHash: member.avatarHash,
|
||||||
|
avatarMime: member.avatarMime,
|
||||||
|
avatarUpdatedAt: member.avatarUpdatedAt,
|
||||||
|
status: 'disconnected',
|
||||||
|
role: member.role,
|
||||||
|
joinedAt: member.joinedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomMemberKey(member: RoomMember): string {
|
||||||
|
return member.oderId || member.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
|
||||||
|
if (!entity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (entity.id) {
|
||||||
|
identifiers.add(entity.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.oderId) {
|
||||||
|
identifiers.add(entity.oderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
|
||||||
|
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
|
||||||
|
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity.presenceServerIds.includes(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
|
||||||
|
const current = this.currentUser();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||||
|
current,
|
||||||
|
this.currentRoom()?.sourceUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
|
||||||
|
const current = this.currentUser();
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addIdentifiers(identifiers, current);
|
||||||
|
|
||||||
|
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
|
||||||
|
current,
|
||||||
|
this.currentRoom()?.sourceUrl
|
||||||
|
)) {
|
||||||
|
identifiers.add(selfId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
||||||
|
this.cancelQueuedProfileCardOpen();
|
||||||
|
this.profileCardOpenTimer = setTimeout(() => {
|
||||||
|
this.profileCardOpenTimer = null;
|
||||||
|
this.profileCard.open(anchor, user, { placement: 'left', editable });
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelQueuedProfileCardOpen(): void {
|
||||||
|
if (!this.profileCardOpenTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(this.profileCardOpenTimer);
|
||||||
|
this.profileCardOpenTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
|
||||||
|
if (!name) {
|
||||||
|
return 'room.channel.nameRequired';
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = this.currentRoom()?.channels ?? [];
|
||||||
|
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
|
||||||
|
|
||||||
|
if (!channelType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
|
||||||
|
return 'room.channel.nameUnique';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
||||||
|
if (
|
||||||
|
!room
|
||||||
|
|| !current?.voiceState?.isConnected
|
||||||
|
|| current.voiceState.roomId !== roomId
|
||||||
|
|| current.voiceState.serverId !== room.id
|
||||||
|
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voiceWorkspace.open(null, { connectRemoteShares: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||||
|
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareVoiceJoin(room: Room, current: User | null): void {
|
||||||
|
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectCurrentVoiceTarget(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
|
||||||
|
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
|
||||||
|
|
||||||
|
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||||
|
this.voiceConnection.clearConnectionError();
|
||||||
|
this.updateVoiceStateStore(roomId, room, current);
|
||||||
|
this.trackCurrentUserMic();
|
||||||
|
this.startVoiceHeartbeat(roomId, room);
|
||||||
|
this.broadcastVoiceConnected(roomId, room, current);
|
||||||
|
this.startVoiceSession(roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleVoiceJoinFailure(error: unknown): void {
|
||||||
|
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
|
||||||
|
|
||||||
|
this.voiceConnection.reportConnectionError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackCurrentUserMic(): void {
|
||||||
|
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||||
|
const micStream = this.voiceConnection.getRawMicStream();
|
||||||
|
|
||||||
|
if (userId && micStream) {
|
||||||
|
this.voiceActivity.trackLocalMic(userId, micStream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private untrackCurrentUserMic(): void {
|
||||||
|
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
this.voiceActivity.untrackLocalMic(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||||
|
if (!current?.id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
|
userId: current.id,
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: current.voiceState?.isMuted ?? false,
|
||||||
|
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||||
|
roomId,
|
||||||
|
serverId: room.id,
|
||||||
|
clientInstanceId: this.realtime.getClientInstanceId()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||||
|
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||||
|
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||||
|
|
||||||
|
this.voiceConnection.broadcastMessage({
|
||||||
|
type: 'voice-state',
|
||||||
|
oderId: current?.oderId || current?.id,
|
||||||
|
displayName: current?.displayName || 'User',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: current?.voiceState?.isMuted ?? false,
|
||||||
|
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||||
|
roomId,
|
||||||
|
serverId: room.id,
|
||||||
|
clientInstanceId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVoiceSession(roomId: string, room: Room): void {
|
||||||
|
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||||
|
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||||
|
|
||||||
|
this.voiceSessionService.startSession({
|
||||||
|
serverId: room.id,
|
||||||
|
serverName: room.name,
|
||||||
|
roomId,
|
||||||
|
roomName: voiceRoomName,
|
||||||
|
serverIcon: room.icon,
|
||||||
|
serverDescription: room.description,
|
||||||
|
serverRoute: `/room/${room.id}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnectCurrentVoiceTarget(current: User | null): void {
|
||||||
|
const previousVoiceState = current?.voiceState;
|
||||||
|
|
||||||
|
this.voiceConnection.stopVoiceHeartbeat();
|
||||||
|
this.untrackCurrentUserMic();
|
||||||
|
this.voiceConnection.disableVoice();
|
||||||
|
|
||||||
|
if (current?.id) {
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
|
userId: current.id,
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
roomId: undefined,
|
||||||
|
serverId: undefined,
|
||||||
|
clientInstanceId: undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateCameraState({
|
||||||
|
userId: current.id,
|
||||||
|
cameraState: { isEnabled: false }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.voiceConnection.broadcastMessage({
|
||||||
|
type: 'voice-state',
|
||||||
|
oderId: current?.oderId || current?.id,
|
||||||
|
displayName: current?.displayName || 'User',
|
||||||
|
voiceState: {
|
||||||
|
isConnected: false,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
roomId: previousVoiceState?.roomId,
|
||||||
|
serverId: previousVoiceState?.serverId,
|
||||||
|
clientInstanceId: undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.voiceSessionService.endSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
|
||||||
|
const room = this.currentRoom();
|
||||||
|
const actor = this.currentUser();
|
||||||
|
|
||||||
|
if (!room || !actor || !this.canMoveVoiceUsers()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
|
||||||
|
|
||||||
|
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedVoiceState: Partial<User['voiceState']> = {
|
||||||
|
isConnected: true,
|
||||||
|
isMuted: targetUser.voiceState.isMuted,
|
||||||
|
isDeafened: targetUser.voiceState.isDeafened,
|
||||||
|
isSpeaking: targetUser.voiceState.isSpeaking,
|
||||||
|
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
|
||||||
|
volume: targetUser.voiceState.volume,
|
||||||
|
roomId: channelId,
|
||||||
|
serverId: room.id
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.updateVoiceState({
|
||||||
|
userId: targetUser.id,
|
||||||
|
voiceState: movedVoiceState
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.realtime.broadcastMessage({
|
||||||
|
type: 'voice-channel-move',
|
||||||
|
roomId: room.id,
|
||||||
|
targetUserId: targetUser.oderId || targetUser.id,
|
||||||
|
voiceState: movedVoiceState,
|
||||||
|
displayName: targetUser.displayName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private isVoiceUserSpeaking(user: User): boolean {
|
private isVoiceUserSpeaking(user: User): boolean {
|
||||||
const userKey = user.oderId || user.id;
|
const userKey = user.oderId || user.id;
|
||||||
|
|
||||||
@@ -1345,4 +1408,5 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
|||||||
private hasActiveVideoStream(stream: MediaStream | null): boolean {
|
private hasActiveVideoStream(stream: MediaStream | null): boolean {
|
||||||
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
|
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
|
||||||
<div
|
<div
|
||||||
#tileRoot
|
#tileRoot
|
||||||
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
|
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -56,29 +55,47 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
||||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
|
||||||
private readonly viewport = inject(ViewportService);
|
|
||||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
|
||||||
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
readonly item = input.required<VoiceWorkspaceStreamItem>();
|
||||||
|
|
||||||
readonly focused = input(false);
|
readonly focused = input(false);
|
||||||
|
|
||||||
readonly featured = input(false);
|
readonly featured = input(false);
|
||||||
|
|
||||||
readonly compact = input(false);
|
readonly compact = input(false);
|
||||||
|
|
||||||
readonly mini = input(false);
|
readonly mini = input(false);
|
||||||
|
|
||||||
readonly immersive = input(false);
|
readonly immersive = input(false);
|
||||||
|
|
||||||
readonly focusRequested = output<string>();
|
readonly focusRequested = output<string>();
|
||||||
|
|
||||||
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
|
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
|
||||||
|
|
||||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||||
|
|
||||||
readonly isFullscreen = signal(false);
|
readonly isFullscreen = signal(false);
|
||||||
|
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
|
||||||
readonly showFullscreenHeader = signal(true);
|
readonly showFullscreenHeader = signal(true);
|
||||||
|
|
||||||
readonly volume = signal(100);
|
readonly volume = signal(100);
|
||||||
|
|
||||||
readonly muted = signal(false);
|
readonly muted = signal(false);
|
||||||
|
|
||||||
|
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||||
|
|
||||||
|
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
void this.mobileLifecycle.initialize();
|
void this.mobileLifecycle.initialize();
|
||||||
this.mobileLifecycle.onAppStateChange((isActive) => {
|
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||||
@@ -172,24 +189,6 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
this.unlockOrientation();
|
this.unlockOrientation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
|
||||||
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
|
||||||
if (isActive) {
|
|
||||||
await this.mobilePictureInPicture.exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const video = this.videoRef()?.nativeElement;
|
|
||||||
|
|
||||||
if (!video || !this.item().stream) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.mobilePictureInPicture.enter(video);
|
|
||||||
}
|
|
||||||
|
|
||||||
canToggleFullscreen(): boolean {
|
canToggleFullscreen(): boolean {
|
||||||
return !this.mini() && !this.compact();
|
return !this.mini() && !this.compact();
|
||||||
}
|
}
|
||||||
@@ -374,6 +373,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
return !item.isLocal && item.hasAudio;
|
return !item.isLocal && item.hasAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleAppStateChange(isActive: boolean): Promise<void> {
|
||||||
|
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
|
||||||
|
if (isActive) {
|
||||||
|
await this.mobilePictureInPicture.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = this.videoRef()?.nativeElement;
|
||||||
|
|
||||||
|
if (!video || !this.item().stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.mobilePictureInPicture.enter(video);
|
||||||
|
}
|
||||||
|
|
||||||
private async enterFullscreen(): Promise<void> {
|
private async enterFullscreen(): Promise<void> {
|
||||||
const tile = this.tileRef()?.nativeElement;
|
const tile = this.tileRef()?.nativeElement;
|
||||||
|
|
||||||
@@ -425,6 +442,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
clearTimeout(this.fullscreenHeaderHideTimeoutId);
|
clearTimeout(this.fullscreenHeaderHideTimeoutId);
|
||||||
this.fullscreenHeaderHideTimeoutId = null;
|
this.fullscreenHeaderHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
|
interface WebKitFullscreenVideoElement extends HTMLVideoElement {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
|
||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
@if (showExpanded()) {
|
@if (showExpanded()) {
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, complexity */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -88,55 +87,42 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class VoiceWorkspaceComponent {
|
export class VoiceWorkspaceComponent {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
||||||
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;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
|
||||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
|
||||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
private readonly remoteStreamRevision = signal(0);
|
|
||||||
|
|
||||||
private readonly miniWindowWidth = 320;
|
|
||||||
private readonly miniWindowHeight = 228;
|
|
||||||
private miniWindowDragging = false;
|
|
||||||
private miniDragOffsetX = 0;
|
|
||||||
private miniDragOffsetY = 0;
|
|
||||||
private wasExpanded = false;
|
|
||||||
private wasAutoHideChrome = false;
|
|
||||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private readonly observedRemoteStreams = new Map<string, {
|
|
||||||
stream: MediaStream;
|
|
||||||
cleanup: () => void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
||||||
|
|
||||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
||||||
|
|
||||||
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
||||||
|
|
||||||
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
||||||
|
|
||||||
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
||||||
|
|
||||||
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
||||||
|
|
||||||
readonly showWorkspaceHeader = signal(true);
|
readonly showWorkspaceHeader = signal(true);
|
||||||
|
|
||||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||||
|
|
||||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||||
|
|
||||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||||
|
|
||||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||||
|
|
||||||
readonly includeSystemAudio = signal(false);
|
readonly includeSystemAudio = signal(false);
|
||||||
|
|
||||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
|
|
||||||
readonly askScreenShareQuality = signal(true);
|
readonly askScreenShareQuality = signal(true);
|
||||||
|
|
||||||
readonly showScreenShareQualityDialog = signal(false);
|
readonly showScreenShareQualityDialog = signal(false);
|
||||||
|
|
||||||
readonly connectedVoiceUsers = computed(() => {
|
readonly connectedVoiceUsers = computed(() => {
|
||||||
@@ -290,18 +276,23 @@ export class VoiceWorkspaceComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
||||||
|
|
||||||
readonly shouldAutoHideChrome = computed(
|
readonly shouldAutoHideChrome = computed(
|
||||||
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||||
|
|
||||||
readonly widescreenShare = computed(
|
readonly widescreenShare = computed(
|
||||||
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
|
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly focusedAudioShare = computed(() => {
|
readonly focusedAudioShare = computed(() => {
|
||||||
const share = this.widescreenShare();
|
const share = this.widescreenShare();
|
||||||
|
|
||||||
return share && !share.isLocal && share.hasAudio ? share : null;
|
return share && !share.isLocal && share.hasAudio ? share : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly focusedShareTitle = computed(() => {
|
readonly focusedShareTitle = computed(() => {
|
||||||
const share = this.widescreenShare();
|
const share = this.widescreenShare();
|
||||||
|
|
||||||
@@ -317,6 +308,7 @@ export class VoiceWorkspaceComponent {
|
|||||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly thumbnailShares = computed(() => {
|
readonly thumbnailShares = computed(() => {
|
||||||
const widescreenShareId = this.widescreenShareId();
|
const widescreenShareId = this.widescreenShareId();
|
||||||
|
|
||||||
@@ -326,9 +318,11 @@ export class VoiceWorkspaceComponent {
|
|||||||
|
|
||||||
return this.activeShares().filter((share) => share.id !== widescreenShareId);
|
return this.activeShares().filter((share) => share.id !== widescreenShareId);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly miniPreviewShare = computed(
|
readonly miniPreviewShare = computed(
|
||||||
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly miniPreviewTitle = computed(() => {
|
readonly miniPreviewTitle = computed(() => {
|
||||||
const previewShare = this.miniPreviewShare();
|
const previewShare = this.miniPreviewShare();
|
||||||
|
|
||||||
@@ -344,7 +338,9 @@ export class VoiceWorkspaceComponent {
|
|||||||
? this.appI18n.instant('voice.workspace.yourCamera')
|
? this.appI18n.instant('voice.workspace.yourCamera')
|
||||||
: this.appI18n.instant('voice.workspace.yourScreen');
|
: this.appI18n.instant('voice.workspace.yourScreen');
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly liveShareCount = computed(() => this.activeShares().length);
|
readonly liveShareCount = computed(() => this.activeShares().length);
|
||||||
|
|
||||||
readonly connectedVoiceChannelName = computed(() => {
|
readonly connectedVoiceChannelName = computed(() => {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -361,21 +357,55 @@ export class VoiceWorkspaceComponent {
|
|||||||
|
|
||||||
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
|
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly serverName = computed(
|
readonly serverName = computed(
|
||||||
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
|
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
|
||||||
);
|
);
|
||||||
|
|
||||||
liveStreamCountLabel(count: number): string {
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
|
||||||
|
|
||||||
return this.appI18n.instant(key, { count });
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
}
|
|
||||||
|
|
||||||
miniWindowStreamHint(count: number): string {
|
private readonly store = inject(Store);
|
||||||
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
|
||||||
|
|
||||||
return this.appI18n.instant(key, { count });
|
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||||
}
|
|
||||||
|
private readonly screenShare = inject(ScreenShareFacade);
|
||||||
|
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||||
|
|
||||||
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly remoteStreamRevision = signal(0);
|
||||||
|
|
||||||
|
private readonly miniWindowWidth = 320;
|
||||||
|
|
||||||
|
private readonly miniWindowHeight = 228;
|
||||||
|
|
||||||
|
private miniWindowDragging = false;
|
||||||
|
|
||||||
|
private miniDragOffsetX = 0;
|
||||||
|
|
||||||
|
private miniDragOffsetY = 0;
|
||||||
|
|
||||||
|
private wasExpanded = false;
|
||||||
|
|
||||||
|
private wasAutoHideChrome = false;
|
||||||
|
|
||||||
|
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private readonly observedRemoteStreams = new Map<string, {
|
||||||
|
stream: MediaStream;
|
||||||
|
cleanup: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.destroyRef.onDestroy(() => {
|
this.destroyRef.onDestroy(() => {
|
||||||
@@ -509,14 +539,6 @@ export class VoiceWorkspaceComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspacePointerMove(): void {
|
|
||||||
if (!this.shouldAutoHideChrome()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.revealWorkspaceChrome();
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('window:mousemove', ['$event'])
|
@HostListener('window:mousemove', ['$event'])
|
||||||
onWindowMouseMove(event: MouseEvent): void {
|
onWindowMouseMove(event: MouseEvent): void {
|
||||||
if (!this.miniWindowDragging) {
|
if (!this.miniWindowDragging) {
|
||||||
@@ -548,6 +570,26 @@ export class VoiceWorkspaceComponent {
|
|||||||
this.ensureMiniWindowPosition();
|
this.ensureMiniWindowPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveStreamCountLabel(count: number): string {
|
||||||
|
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
|
||||||
|
|
||||||
|
return this.appI18n.instant(key, { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
miniWindowStreamHint(count: number): string {
|
||||||
|
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
|
||||||
|
|
||||||
|
return this.appI18n.instant(key, { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
onWorkspacePointerMove(): void {
|
||||||
|
if (!this.shouldAutoHideChrome()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.revealWorkspaceChrome();
|
||||||
|
}
|
||||||
|
|
||||||
trackUser(index: number, user: User): string {
|
trackUser(index: number, user: User): string {
|
||||||
return this.getUserPeerKey(user) || `${index}`;
|
return this.getUserPeerKey(user) || `${index}`;
|
||||||
}
|
}
|
||||||
@@ -1057,4 +1099,5 @@ export class VoiceWorkspaceComponent {
|
|||||||
private clamp(value: number, min: number, max: number): number {
|
private clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -70,36 +69,32 @@ const ACTIVATION_DEBOUNCE_MS = 150;
|
|||||||
templateUrl: './servers-rail.component.html'
|
templateUrl: './servers-rail.component.html'
|
||||||
})
|
})
|
||||||
export class ServersRailComponent {
|
export class ServersRailComponent {
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private store = inject(Store);
|
|
||||||
private router = inject(Router);
|
|
||||||
private voiceSession = inject(VoiceSessionFacade);
|
|
||||||
private db = inject(DatabaseService);
|
|
||||||
private notifications = inject(NotificationsFacade);
|
|
||||||
readonly directCalls = inject(DirectCallService);
|
readonly directCalls = inject(DirectCallService);
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private destroyRef = inject(DestroyRef);
|
|
||||||
private banLookupRequestVersion = 0;
|
|
||||||
private bannedLookupUserKey: string | null = null;
|
|
||||||
private activationRequestVersion = 0;
|
|
||||||
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
|
||||||
private joinRequestVersion = 0;
|
|
||||||
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
|
||||||
private visibleSavedRoomCache: Room[] = [];
|
|
||||||
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
|
|
||||||
menuX = signal(72);
|
menuX = signal(72);
|
||||||
|
|
||||||
menuY = signal(100);
|
menuY = signal(100);
|
||||||
|
|
||||||
contextRoom = signal<Room | null>(null);
|
contextRoom = signal<Room | null>(null);
|
||||||
|
|
||||||
optimisticSelectedRoomId = signal<string | null>(null);
|
optimisticSelectedRoomId = signal<string | null>(null);
|
||||||
|
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
|
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
|
|
||||||
isOnServers = toSignal(
|
isOnServers = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -107,6 +102,7 @@ export class ServersRailComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/servers') }
|
{ initialValue: this.router.url.startsWith('/servers') }
|
||||||
);
|
);
|
||||||
|
|
||||||
isOnDirectMessage = toSignal(
|
isOnDirectMessage = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -114,6 +110,7 @@ export class ServersRailComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.isDirectMessageUrl(this.router.url) }
|
{ initialValue: this.isDirectMessageUrl(this.router.url) }
|
||||||
);
|
);
|
||||||
|
|
||||||
isOnDashboard = toSignal(
|
isOnDashboard = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -121,6 +118,7 @@ export class ServersRailComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/dashboard') }
|
{ initialValue: this.router.url.startsWith('/dashboard') }
|
||||||
);
|
);
|
||||||
|
|
||||||
isOnCall = toSignal(
|
isOnCall = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -128,6 +126,7 @@ export class ServersRailComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/call/') }
|
{ initialValue: this.router.url.startsWith('/call/') }
|
||||||
);
|
);
|
||||||
|
|
||||||
currentCallId = toSignal(
|
currentCallId = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -135,6 +134,7 @@ export class ServersRailComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.callIdFromUrl(this.router.url) }
|
{ initialValue: this.callIdFromUrl(this.router.url) }
|
||||||
);
|
);
|
||||||
|
|
||||||
selectedCallIndex = computed(() => {
|
selectedCallIndex = computed(() => {
|
||||||
const routeCallId = this.currentCallId();
|
const routeCallId = this.currentCallId();
|
||||||
const visibleCalls = this.directCalls.visibleActiveSessions();
|
const visibleCalls = this.directCalls.visibleActiveSessions();
|
||||||
@@ -155,13 +155,21 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
|
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
|
||||||
});
|
});
|
||||||
|
|
||||||
bannedServerName = signal('');
|
bannedServerName = signal('');
|
||||||
|
|
||||||
showBannedDialog = signal(false);
|
showBannedDialog = signal(false);
|
||||||
|
|
||||||
showPasswordDialog = signal(false);
|
showPasswordDialog = signal(false);
|
||||||
|
|
||||||
passwordPromptRoom = signal<Room | null>(null);
|
passwordPromptRoom = signal<Room | null>(null);
|
||||||
|
|
||||||
joinPassword = signal('');
|
joinPassword = signal('');
|
||||||
|
|
||||||
joinPasswordError = signal<string | null>(null);
|
joinPasswordError = signal<string | null>(null);
|
||||||
|
|
||||||
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
|
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
|
||||||
|
|
||||||
voicePresenceByRoom = computed(() => {
|
voicePresenceByRoom = computed(() => {
|
||||||
const presence: Record<string, number> = {};
|
const presence: Record<string, number> = {};
|
||||||
const seenByRoom = new Map<string, Set<string>>();
|
const seenByRoom = new Map<string, Set<string>>();
|
||||||
@@ -203,6 +211,38 @@ export class ServersRailComponent {
|
|||||||
return presence;
|
return presence;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private voiceSession = inject(VoiceSessionFacade);
|
||||||
|
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
|
||||||
|
private notifications = inject(NotificationsFacade);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
|
private bannedLookupUserKey: string | null = null;
|
||||||
|
|
||||||
|
private activationRequestVersion = 0;
|
||||||
|
|
||||||
|
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||||
|
|
||||||
|
private joinRequestVersion = 0;
|
||||||
|
|
||||||
|
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||||
|
|
||||||
|
private visibleSavedRoomCache: Room[] = [];
|
||||||
|
|
||||||
|
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const rooms = this.savedRooms();
|
const rooms = this.savedRooms();
|
||||||
@@ -241,6 +281,8 @@ export class ServersRailComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackRoomId = (index: number, room: Room) => room.id;
|
||||||
|
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
if (!name)
|
if (!name)
|
||||||
return '?';
|
return '?';
|
||||||
@@ -250,8 +292,6 @@ export class ServersRailComponent {
|
|||||||
return ch || '?';
|
return ch || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
trackRoomId = (index: number, room: Room) => room.id;
|
|
||||||
|
|
||||||
goToDashboard(): void {
|
goToDashboard(): void {
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
@@ -342,13 +382,6 @@ export class ServersRailComponent {
|
|||||||
return !!this.bannedRoomLookup()[room.id];
|
return !!this.bannedRoomLookup()[room.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
private callIdFromUrl(url: string): string | null {
|
|
||||||
const path = url.split(/[?#]/, 1)[0];
|
|
||||||
const match = path.match(/^\/call\/([^/]+)/);
|
|
||||||
|
|
||||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextRoom.set(room);
|
this.contextRoom.set(room);
|
||||||
@@ -454,6 +487,13 @@ export class ServersRailComponent {
|
|||||||
return this.currentRoom()?.id === room.id;
|
return this.currentRoom()?.id === room.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private callIdFromUrl(url: string): string | null {
|
||||||
|
const path = url.split(/[?#]/, 1)[0];
|
||||||
|
const match = path.match(/^\/call\/([^/]+)/);
|
||||||
|
|
||||||
|
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
|
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
|
||||||
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
|
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
|
||||||
const stabilizedRooms = nextRooms.map((room) => {
|
const stabilizedRooms = nextRooms.map((room) => {
|
||||||
@@ -806,4 +846,5 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
return nextRoom;
|
return nextRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
effect,
|
effect,
|
||||||
@@ -35,17 +34,21 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|||||||
templateUrl: './bans-settings.component.html'
|
templateUrl: './bans-settings.component.html'
|
||||||
})
|
})
|
||||||
export class BansSettingsComponent {
|
export class BansSettingsComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private actions$ = inject(Actions);
|
|
||||||
private db = inject(DatabaseService);
|
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
/** The currently selected server, passed from the parent. */
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
|
|
||||||
/** Whether the current user is admin of this server. */
|
/** Whether the current user is admin of this server. */
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
|
||||||
bannedUsers = signal<BanEntry[]>([]);
|
bannedUsers = signal<BanEntry[]>([]);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private actions$ = inject(Actions);
|
||||||
|
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const roomId = this.server()?.id;
|
const roomId = this.server()?.id;
|
||||||
@@ -82,10 +85,6 @@ export class BansSettingsComponent {
|
|||||||
oderId: ban.oderId }));
|
oderId: ban.oderId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadBansForServer(roomId: string): Promise<void> {
|
|
||||||
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
|
||||||
}
|
|
||||||
|
|
||||||
formatExpiry(timestamp: number): string {
|
formatExpiry(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
@@ -96,4 +95,9 @@ export class BansSettingsComponent {
|
|||||||
minute: '2-digit' })
|
minute: '2-digit' })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadBansForServer(roomId: string): Promise<void> {
|
||||||
|
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -41,16 +40,23 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
|
|||||||
templateUrl: './data-settings.component.html'
|
templateUrl: './data-settings.component.html'
|
||||||
})
|
})
|
||||||
export class DataSettingsComponent {
|
export class DataSettingsComponent {
|
||||||
private readonly electron = inject(ElectronBridgeService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly isElectron = this.electron.isAvailable;
|
readonly isElectron = this.electron.isAvailable;
|
||||||
|
|
||||||
readonly dataPath = signal<string | null>(null);
|
readonly dataPath = signal<string | null>(null);
|
||||||
|
|
||||||
readonly busyAction = signal<DataAction | null>(null);
|
readonly busyAction = signal<DataAction | null>(null);
|
||||||
|
|
||||||
readonly statusMessage = signal<string | null>(null);
|
readonly statusMessage = signal<string | null>(null);
|
||||||
|
|
||||||
readonly errorMessage = signal<string | null>(null);
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
readonly restartRequired = signal(false);
|
readonly restartRequired = signal(false);
|
||||||
|
|
||||||
|
private readonly electron = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
void this.loadDataPath();
|
void this.loadDataPath();
|
||||||
}
|
}
|
||||||
@@ -145,4 +151,5 @@ export class DataSettingsComponent {
|
|||||||
this.busyAction.set(null);
|
this.busyAction.set(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -49,35 +48,46 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
|
|||||||
templateUrl: './debugging-settings.component.html'
|
templateUrl: './debugging-settings.component.html'
|
||||||
})
|
})
|
||||||
export class DebuggingSettingsComponent {
|
export class DebuggingSettingsComponent {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
|
||||||
private readonly platform = inject(PlatformService);
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
|
||||||
readonly debugging = inject(DebuggingService);
|
readonly debugging = inject(DebuggingService);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
readonly isElectron = this.platform.isElectron;
|
||||||
|
|
||||||
readonly ramLabel = signal<string | null>(null);
|
readonly ramLabel = signal<string | null>(null);
|
||||||
|
|
||||||
readonly enabled = this.debugging.enabled;
|
readonly enabled = this.debugging.enabled;
|
||||||
|
|
||||||
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
readonly isConsoleOpen = this.debugging.isConsoleOpen;
|
||||||
|
|
||||||
readonly entryCount = computed(() => {
|
readonly entryCount = computed(() => {
|
||||||
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
|
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly errorCount = computed(() => {
|
readonly errorCount = computed(() => {
|
||||||
return this.debugging.entries().reduce((sum, entry) => {
|
return this.debugging.entries().reduce((sum, entry) => {
|
||||||
return sum + (entry.level === 'error' ? entry.count : 0);
|
return sum + (entry.level === 'error' ? entry.count : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly warningCount = computed(() => {
|
readonly warningCount = computed(() => {
|
||||||
return this.debugging.entries().reduce((sum, entry) => {
|
return this.debugging.entries().reduce((sum, entry) => {
|
||||||
return sum + (entry.level === 'warn' ? entry.count : 0);
|
return sum + (entry.level === 'warn' ? entry.count : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly lastUpdatedLabel = computed(() => {
|
readonly lastUpdatedLabel = computed(() => {
|
||||||
const lastEntry = this.debugging.entries().at(-1);
|
const lastEntry = this.debugging.entries().at(-1);
|
||||||
|
|
||||||
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
|
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (this.isElectron)
|
if (this.isElectron)
|
||||||
this.startRamPolling();
|
this.startRamPolling();
|
||||||
@@ -118,4 +128,5 @@ export class DebuggingSettingsComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -34,20 +33,30 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
|||||||
templateUrl: './general-settings.component.html'
|
templateUrl: './general-settings.component.html'
|
||||||
})
|
})
|
||||||
export class GeneralSettingsComponent {
|
export class GeneralSettingsComponent {
|
||||||
private platform = inject(PlatformService);
|
|
||||||
private electronBridge = inject(ElectronBridgeService);
|
|
||||||
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
readonly isElectron = this.platform.isElectron;
|
||||||
|
|
||||||
reopenLastViewedChat = signal(true);
|
reopenLastViewedChat = signal(true);
|
||||||
|
|
||||||
autoStart = signal(false);
|
autoStart = signal(false);
|
||||||
|
|
||||||
closeToTray = signal(true);
|
closeToTray = signal(true);
|
||||||
|
|
||||||
savingAutoStart = signal(false);
|
savingAutoStart = signal(false);
|
||||||
|
|
||||||
savingCloseToTray = signal(false);
|
savingCloseToTray = signal(false);
|
||||||
|
|
||||||
ignoredGameProcesses = signal<string[]>([]);
|
ignoredGameProcesses = signal<string[]>([]);
|
||||||
|
|
||||||
ignoredProcessDraft = signal('');
|
ignoredProcessDraft = signal('');
|
||||||
|
|
||||||
savingIgnoredGameProcesses = signal(false);
|
savingIgnoredGameProcesses = signal(false);
|
||||||
|
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadGeneralSettings();
|
this.loadGeneralSettings();
|
||||||
|
|
||||||
@@ -119,31 +128,6 @@ export class GeneralSettingsComponent {
|
|||||||
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
|
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadDesktopSettings(): Promise<void> {
|
|
||||||
const api = this.electronBridge.getApi();
|
|
||||||
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await api.getDesktopSettings();
|
|
||||||
|
|
||||||
this.applyDesktopSettings(snapshot);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadGeneralSettings(): void {
|
|
||||||
const settings = loadGeneralSettingsFromStorage();
|
|
||||||
|
|
||||||
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
|
||||||
this.autoStart.set(snapshot.autoStart);
|
|
||||||
this.closeToTray.set(snapshot.closeToTray);
|
|
||||||
}
|
|
||||||
|
|
||||||
onIgnoredProcessDraftChange(event: Event): void {
|
onIgnoredProcessDraftChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
|
|
||||||
@@ -169,6 +153,31 @@ export class GeneralSettingsComponent {
|
|||||||
await this.saveIgnoredGameProcesses(next);
|
await this.saveIgnoredGameProcesses(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadDesktopSettings(): Promise<void> {
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await api.getDesktopSettings();
|
||||||
|
|
||||||
|
this.applyDesktopSettings(snapshot);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadGeneralSettings(): void {
|
||||||
|
const settings = loadGeneralSettingsFromStorage();
|
||||||
|
|
||||||
|
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
||||||
|
this.autoStart.set(snapshot.autoStart);
|
||||||
|
this.closeToTray.set(snapshot.closeToTray);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadIgnoredGameProcesses(): Promise<void> {
|
private async loadIgnoredGameProcesses(): Promise<void> {
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
|
|
||||||
@@ -200,4 +209,5 @@ export class GeneralSettingsComponent {
|
|||||||
this.savingIgnoredGameProcesses.set(false);
|
this.savingIgnoredGameProcesses.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -52,17 +51,23 @@ import {
|
|||||||
templateUrl: './ice-server-settings.component.html'
|
templateUrl: './ice-server-settings.component.html'
|
||||||
})
|
})
|
||||||
export class IceServerSettingsComponent {
|
export class IceServerSettingsComponent {
|
||||||
private iceSettings = inject(IceServerSettingsService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
entries = this.iceSettings.entries;
|
entries = this.iceSettings.entries;
|
||||||
|
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
|
|
||||||
newType: 'stun' | 'turn' = 'stun';
|
newType: 'stun' | 'turn' = 'stun';
|
||||||
|
|
||||||
newUrl = '';
|
newUrl = '';
|
||||||
|
|
||||||
newUsername = '';
|
newUsername = '';
|
||||||
|
|
||||||
newCredential = '';
|
newCredential = '';
|
||||||
|
|
||||||
|
private iceSettings = inject(IceServerSettingsService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
addEntry(): void {
|
addEntry(): void {
|
||||||
this.addError.set(null);
|
this.addError.set(null);
|
||||||
|
|
||||||
@@ -134,4 +139,5 @@ export class IceServerSettingsComponent {
|
|||||||
trackEntry(_index: number, entry: IceServerEntry): string {
|
trackEntry(_index: number, entry: IceServerEntry): string {
|
||||||
return entry.id;
|
return entry.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
@@ -29,8 +28,6 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
|||||||
templateUrl: './local-api-settings.component.html'
|
templateUrl: './local-api-settings.component.html'
|
||||||
})
|
})
|
||||||
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||||
private readonly bridge = inject(ElectronBridgeService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
readonly isElectron = this.bridge.isAvailable;
|
readonly isElectron = this.bridge.isAvailable;
|
||||||
|
|
||||||
@@ -55,10 +52,15 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
readonly allowedServersText = signal('');
|
readonly allowedServersText = signal('');
|
||||||
|
|
||||||
readonly hasPendingAllowedServersChanges = signal(false);
|
readonly hasPendingAllowedServersChanges = signal(false);
|
||||||
|
|
||||||
readonly portText = signal('17878');
|
readonly portText = signal('17878');
|
||||||
|
|
||||||
readonly hasPendingPortChange = signal(false);
|
readonly hasPendingPortChange = signal(false);
|
||||||
|
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
|
|
||||||
readonly errorMessage = signal<string | null>(null);
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
readonly statusLabel = computed(() => {
|
readonly statusLabel = computed(() => {
|
||||||
@@ -81,6 +83,10 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly bridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
@@ -278,4 +284,5 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
|||||||
this.busy.set(false);
|
this.busy.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -56,23 +55,27 @@ interface ServerMemberView extends RoomMember {
|
|||||||
templateUrl: './members-settings.component.html'
|
templateUrl: './members-settings.component.html'
|
||||||
})
|
})
|
||||||
export class MembersSettingsComponent {
|
export class MembersSettingsComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private readonly i18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
/** The currently selected server, passed from the parent. */
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
|
|
||||||
/** Whether the current user is admin of this server. */
|
/** Whether the current user is admin of this server. */
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
|
||||||
accessRole = input<string | null>(null);
|
accessRole = input<string | null>(null);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
usersEntities = this.store.selectSignal(selectUsersEntities);
|
usersEntities = this.store.selectSignal(selectUsersEntities);
|
||||||
|
|
||||||
normalizedServer = computed(() => {
|
normalizedServer = computed(() => {
|
||||||
const room = this.server();
|
const room = this.server();
|
||||||
|
|
||||||
return room ? normalizeRoomAccessControl(room) : null;
|
return room ? normalizeRoomAccessControl(room) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
|
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
|
||||||
|
|
||||||
members = computed<ServerMemberView[]>(() => {
|
members = computed<ServerMemberView[]>(() => {
|
||||||
@@ -104,6 +107,10 @@ export class MembersSettingsComponent {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private readonly i18n = inject(AppI18nService);
|
||||||
|
|
||||||
canChangeRoles(member: ServerMemberView): boolean {
|
canChangeRoles(member: ServerMemberView): boolean {
|
||||||
const room = this.normalizedServer();
|
const room = this.normalizedServer();
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
@@ -163,4 +170,5 @@ export class MembersSettingsComponent {
|
|||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
displayName: member.displayName }));
|
displayName: member.displayName }));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -53,25 +52,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
|||||||
templateUrl: './network-settings.component.html'
|
templateUrl: './network-settings.component.html'
|
||||||
})
|
})
|
||||||
export class NetworkSettingsComponent {
|
export class NetworkSettingsComponent {
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly signalServerAuth = inject(SignalServerAuthService);
|
readonly signalServerAuth = inject(SignalServerAuthService);
|
||||||
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
|
||||||
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
|
||||||
readonly provisionNotice = this.provisionNoticeService.notice;
|
readonly provisionNotice = this.provisionNoticeService.notice;
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
|
|
||||||
activeServers = this.serverDirectory.activeServers;
|
activeServers = this.serverDirectory.activeServers;
|
||||||
|
|
||||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||||
|
|
||||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||||
|
|
||||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||||
|
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
|
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
|
|
||||||
newServerName = '';
|
newServerName = '';
|
||||||
|
|
||||||
newServerUrl = '';
|
newServerUrl = '';
|
||||||
|
|
||||||
autoReconnect = true;
|
autoReconnect = true;
|
||||||
|
|
||||||
searchAllServers = true;
|
searchAllServers = true;
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
||||||
|
|
||||||
|
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadConnectionSettings();
|
this.loadConnectionSettings();
|
||||||
}
|
}
|
||||||
@@ -166,4 +180,5 @@ export class NetworkSettingsComponent {
|
|||||||
|
|
||||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
@@ -92,12 +91,13 @@ function upsertRoleChannelOverride(
|
|||||||
templateUrl: './permissions-settings.component.html'
|
templateUrl: './permissions-settings.component.html'
|
||||||
})
|
})
|
||||||
export class PermissionsSettingsComponent {
|
export class PermissionsSettingsComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
|
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
permissionDefinitions = computed(() =>
|
permissionDefinitions = computed(() =>
|
||||||
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
|
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
|
||||||
key: definition.key,
|
key: definition.key,
|
||||||
@@ -105,24 +105,30 @@ export class PermissionsSettingsComponent {
|
|||||||
description: this.appI18n.instant(`permissions.${definition.key}.description`)
|
description: this.appI18n.instant(`permissions.${definition.key}.description`)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
permissionStates: PermissionState[] = [
|
permissionStates: PermissionState[] = [
|
||||||
'inherit',
|
'inherit',
|
||||||
'allow',
|
'allow',
|
||||||
'deny'
|
'deny'
|
||||||
];
|
];
|
||||||
|
|
||||||
normalizedServer = computed(() => {
|
normalizedServer = computed(() => {
|
||||||
const room = this.server();
|
const room = this.server();
|
||||||
|
|
||||||
return room ? normalizeRoomAccessControl(room) : null;
|
return room ? normalizeRoomAccessControl(room) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
|
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
|
||||||
|
|
||||||
channels = computed(() => this.normalizedServer()?.channels ?? []);
|
channels = computed(() => this.normalizedServer()?.channels ?? []);
|
||||||
|
|
||||||
canManageRoles = computed(() => {
|
canManageRoles = computed(() => {
|
||||||
const room = this.normalizedServer();
|
const room = this.normalizedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
|
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
|
||||||
});
|
});
|
||||||
|
|
||||||
canManageServer = computed(() => {
|
canManageServer = computed(() => {
|
||||||
const room = this.normalizedServer();
|
const room = this.normalizedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -131,10 +137,17 @@ export class PermissionsSettingsComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
selectedRoleKey: string | null = null;
|
selectedRoleKey: string | null = null;
|
||||||
|
|
||||||
selectedChannelKey = '';
|
selectedChannelKey = '';
|
||||||
|
|
||||||
roleName = '';
|
roleName = '';
|
||||||
|
|
||||||
roleColor = '#94a3b8';
|
roleColor = '#94a3b8';
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const room = this.normalizedServer();
|
const room = this.normalizedServer();
|
||||||
@@ -406,4 +419,5 @@ export class PermissionsSettingsComponent {
|
|||||||
trackRole(_: number, role: RoomRole): string {
|
trackRole(_: number, role: RoomRole): string {
|
||||||
return role.id;
|
return role.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
effect,
|
effect,
|
||||||
@@ -54,37 +53,54 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
|
|||||||
templateUrl: './server-settings.component.html'
|
templateUrl: './server-settings.component.html'
|
||||||
})
|
})
|
||||||
export class ServerSettingsComponent {
|
export class ServerSettingsComponent {
|
||||||
private store = inject(Store);
|
|
||||||
private modal = inject(SettingsModalService);
|
|
||||||
private serverIconImages = inject(ServerIconImageService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
/** The currently selected server, passed from the parent. */
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
|
|
||||||
/** Whether the current user is admin of this server. */
|
/** Whether the current user is admin of this server. */
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
|
||||||
/** Whether the current user can manage this server's icon. */
|
/** Whether the current user can manage this server's icon. */
|
||||||
canManageIcon = input(false);
|
canManageIcon = input(false);
|
||||||
|
|
||||||
/** Whether the current user can delete this server. */
|
/** Whether the current user can delete this server. */
|
||||||
canDeleteServer = input(false);
|
canDeleteServer = input(false);
|
||||||
|
|
||||||
roomName = '';
|
roomName = '';
|
||||||
|
|
||||||
roomDescription = '';
|
roomDescription = '';
|
||||||
|
|
||||||
isPrivate = signal(false);
|
isPrivate = signal(false);
|
||||||
|
|
||||||
hasPassword = signal(false);
|
hasPassword = signal(false);
|
||||||
|
|
||||||
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
|
||||||
|
|
||||||
passwordError = signal<string | null>(null);
|
passwordError = signal<string | null>(null);
|
||||||
|
|
||||||
roomPassword = '';
|
roomPassword = '';
|
||||||
|
|
||||||
maxUsers = 0;
|
maxUsers = 0;
|
||||||
|
|
||||||
showDeleteConfirm = signal(false);
|
showDeleteConfirm = signal(false);
|
||||||
|
|
||||||
iconError = signal<string | null>(null);
|
iconError = signal<string | null>(null);
|
||||||
|
|
||||||
saveSuccess = signal<string | null>(null);
|
saveSuccess = signal<string | null>(null);
|
||||||
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/** Reload form fields whenever the server input changes. */
|
/** Reload form fields whenever the server input changes. */
|
||||||
readonly serverData = this.server;
|
readonly serverData = this.server;
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private modal = inject(SettingsModalService);
|
||||||
|
|
||||||
|
private serverIconImages = inject(ServerIconImageService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const room = this.server();
|
const room = this.server();
|
||||||
@@ -243,4 +259,5 @@ export class ServerSettingsComponent {
|
|||||||
|
|
||||||
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
|
||||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -104,14 +103,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
|||||||
})
|
})
|
||||||
export class SettingsModalComponent {
|
export class SettingsModalComponent {
|
||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private theme = inject(ThemeService);
|
|
||||||
private themeLibrary = inject(ThemeLibraryService);
|
|
||||||
private viewport = inject(ViewportService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||||
private lastRequestedServerId: string | null = null;
|
|
||||||
|
|
||||||
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
|
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
@@ -124,21 +117,30 @@ export class SettingsModalComponent {
|
|||||||
*/
|
*/
|
||||||
readonly mobilePage = signal<'menu' | 'detail'>('menu');
|
readonly mobilePage = signal<'menu' | 'detail'>('menu');
|
||||||
|
|
||||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
|
||||||
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
isOpen = this.modal.isOpen;
|
isOpen = this.modal.isOpen;
|
||||||
|
|
||||||
activePage = this.modal.activePage;
|
activePage = this.modal.activePage;
|
||||||
|
|
||||||
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
||||||
|
|
||||||
themeStudioMinimized = this.modal.themeStudioMinimized;
|
themeStudioMinimized = this.modal.themeStudioMinimized;
|
||||||
|
|
||||||
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
||||||
|
|
||||||
activeThemeName = this.theme.activeThemeName;
|
activeThemeName = this.theme.activeThemeName;
|
||||||
|
|
||||||
savedThemesAvailable = this.themeLibrary.isAvailable;
|
savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||||
|
|
||||||
savedThemes = this.themeLibrary.entries;
|
savedThemes = this.themeLibrary.entries;
|
||||||
|
|
||||||
savedThemesBusy = this.themeLibrary.isBusy;
|
savedThemesBusy = this.themeLibrary.isBusy;
|
||||||
|
|
||||||
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||||
|
|
||||||
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||||
@@ -153,6 +155,7 @@ export class SettingsModalComponent {
|
|||||||
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
|
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
|
||||||
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
|
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
|
||||||
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
|
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
|
||||||
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
|
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
|
||||||
@@ -192,6 +195,7 @@ export class SettingsModalComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
selectedServerId = signal<string | null>(null);
|
selectedServerId = signal<string | null>(null);
|
||||||
|
|
||||||
selectedServer = computed<Room | null>(() => {
|
selectedServer = computed<Room | null>(() => {
|
||||||
const id = this.selectedServerId();
|
const id = this.selectedServerId();
|
||||||
const currentRoom = this.currentRoom();
|
const currentRoom = this.currentRoom();
|
||||||
@@ -294,8 +298,25 @@ export class SettingsModalComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
animating = signal(false);
|
animating = signal(false);
|
||||||
|
|
||||||
showThirdPartyLicenses = signal(false);
|
showThirdPartyLicenses = signal(false);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private theme = inject(ThemeService);
|
||||||
|
|
||||||
|
private themeLibrary = inject(ThemeLibraryService);
|
||||||
|
|
||||||
|
private viewport = inject(ViewportService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private lastRequestedServerId: string | null = null;
|
||||||
|
|
||||||
|
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (!this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
@@ -482,4 +503,5 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,23 +31,34 @@ type DesktopUpdateStatus =
|
|||||||
templateUrl: './updates-settings.component.html'
|
templateUrl: './updates-settings.component.html'
|
||||||
})
|
})
|
||||||
export class UpdatesSettingsComponent {
|
export class UpdatesSettingsComponent {
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly desktopUpdates = inject(DesktopAppUpdateService);
|
readonly desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
|
|
||||||
readonly mobileUpdates = inject(MobileAppUpdateService);
|
readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||||
|
|
||||||
readonly isElectron = this.desktopUpdates.isElectron;
|
readonly isElectron = this.desktopUpdates.isElectron;
|
||||||
|
|
||||||
readonly isCapacitor = this.mobileUpdates.isCapacitor;
|
readonly isCapacitor = this.mobileUpdates.isCapacitor;
|
||||||
|
|
||||||
readonly state = this.desktopUpdates.state;
|
readonly state = this.desktopUpdates.state;
|
||||||
|
|
||||||
readonly mobileState = this.mobileUpdates.state;
|
readonly mobileState = this.mobileUpdates.state;
|
||||||
|
|
||||||
readonly mobileStatusLabel = computed(() =>
|
readonly mobileStatusLabel = computed(() =>
|
||||||
this.getMobileStatusLabel(this.mobileState().status)
|
this.getMobileStatusLabel(this.mobileState().status)
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly hasPendingManifestUrlChanges = signal(false);
|
readonly hasPendingManifestUrlChanges = signal(false);
|
||||||
|
|
||||||
readonly manifestUrlsText = signal('');
|
readonly manifestUrlsText = signal('');
|
||||||
|
|
||||||
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
|
||||||
|
|
||||||
readonly isUsingConnectedServerDefaults = computed(() => {
|
readonly isUsingConnectedServerDefaults = computed(() => {
|
||||||
return this.state().configuredManifestUrls.length === 0;
|
return this.state().configuredManifestUrls.length === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.hasPendingManifestUrlChanges()) {
|
if (this.hasPendingManifestUrlChanges()) {
|
||||||
@@ -178,4 +189,5 @@ export class UpdatesSettingsComponent {
|
|||||||
|
|
||||||
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
|
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -53,13 +52,10 @@ interface AudioDevice {
|
|||||||
templateUrl: './voice-settings.component.html'
|
templateUrl: './voice-settings.component.html'
|
||||||
})
|
})
|
||||||
export class VoiceSettingsComponent {
|
export class VoiceSettingsComponent {
|
||||||
private voiceConnection = inject(VoiceConnectionFacade);
|
|
||||||
private voicePlayback = inject(VoicePlaybackService);
|
|
||||||
private electronBridge = inject(ElectronBridgeService);
|
|
||||||
private platform = inject(PlatformService);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
readonly audioService = inject(NotificationAudioService);
|
readonly audioService = inject(NotificationAudioService);
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
readonly isElectron = this.platform.isElectron;
|
||||||
|
|
||||||
readonly screenShareQualityOptions = computed(() =>
|
readonly screenShareQualityOptions = computed(() =>
|
||||||
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
|
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
|
||||||
id: option.id,
|
id: option.id,
|
||||||
@@ -69,26 +65,51 @@ export class VoiceSettingsComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
inputDevices = signal<AudioDevice[]>([]);
|
inputDevices = signal<AudioDevice[]>([]);
|
||||||
|
|
||||||
outputDevices = signal<AudioDevice[]>([]);
|
outputDevices = signal<AudioDevice[]>([]);
|
||||||
|
|
||||||
selectedInputDevice = signal<string>('');
|
selectedInputDevice = signal<string>('');
|
||||||
|
|
||||||
selectedOutputDevice = signal<string>('');
|
selectedOutputDevice = signal<string>('');
|
||||||
|
|
||||||
inputVolume = signal(100);
|
inputVolume = signal(100);
|
||||||
|
|
||||||
outputVolume = signal(100);
|
outputVolume = signal(100);
|
||||||
|
|
||||||
audioBitrate = signal(96);
|
audioBitrate = signal(96);
|
||||||
|
|
||||||
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
|
||||||
|
|
||||||
includeSystemAudio = signal(false);
|
includeSystemAudio = signal(false);
|
||||||
|
|
||||||
noiseReduction = signal(true);
|
noiseReduction = signal(true);
|
||||||
|
|
||||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
|
|
||||||
askScreenShareQuality = signal(true);
|
askScreenShareQuality = signal(true);
|
||||||
|
|
||||||
hardwareAcceleration = signal(true);
|
hardwareAcceleration = signal(true);
|
||||||
|
|
||||||
hardwareAccelerationRestartRequired = signal(false);
|
hardwareAccelerationRestartRequired = signal(false);
|
||||||
|
|
||||||
readonly selectedScreenShareQualityDescription = computed(
|
readonly selectedScreenShareQualityDescription = computed(
|
||||||
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
readonly notificationVolumePercent = computed(() =>
|
readonly notificationVolumePercent = computed(() =>
|
||||||
String(Math.round(this.audioService.notificationVolume() * 100))
|
String(Math.round(this.audioService.notificationVolume() * 100))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private voiceConnection = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private voicePlayback = inject(VoicePlaybackService);
|
||||||
|
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadVoiceSettings();
|
this.loadVoiceSettings();
|
||||||
this.loadAudioDevices();
|
this.loadAudioDevices();
|
||||||
@@ -291,4 +312,5 @@ export class VoiceSettingsComponent {
|
|||||||
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
|
||||||
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
|
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -63,26 +62,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/dir
|
|||||||
* Settings page for managing signaling servers and connection preferences.
|
* Settings page for managing signaling servers and connection preferences.
|
||||||
*/
|
*/
|
||||||
export class SettingsComponent implements OnInit {
|
export class SettingsComponent implements OnInit {
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private voiceConnection = inject(VoiceConnectionFacade);
|
|
||||||
private router = inject(Router);
|
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
audioService = inject(NotificationAudioService);
|
audioService = inject(NotificationAudioService);
|
||||||
|
|
||||||
servers = this.serverDirectory.servers;
|
servers = this.serverDirectory.servers;
|
||||||
|
|
||||||
activeServers = this.serverDirectory.activeServers;
|
activeServers = this.serverDirectory.activeServers;
|
||||||
|
|
||||||
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
|
||||||
|
|
||||||
hasMultipleServers = computed(() => this.servers().length > 1);
|
hasMultipleServers = computed(() => this.servers().length > 1);
|
||||||
|
|
||||||
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
|
||||||
|
|
||||||
isTesting = signal(false);
|
isTesting = signal(false);
|
||||||
|
|
||||||
addError = signal<string | null>(null);
|
addError = signal<string | null>(null);
|
||||||
|
|
||||||
newServerName = '';
|
newServerName = '';
|
||||||
|
|
||||||
newServerUrl = '';
|
newServerUrl = '';
|
||||||
|
|
||||||
autoReconnect = true;
|
autoReconnect = true;
|
||||||
|
|
||||||
searchAllServers = true;
|
searchAllServers = true;
|
||||||
|
|
||||||
noiseReduction = true;
|
noiseReduction = true;
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private voiceConnection = inject(VoiceConnectionFacade);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
/** Load persisted connection settings on component init. */
|
/** Load persisted connection settings on component init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadConnectionSettings();
|
this.loadConnectionSettings();
|
||||||
@@ -233,4 +246,5 @@ export class SettingsComponent implements OnInit {
|
|||||||
|
|
||||||
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,19 @@ const NON_TEXT_INPUT_TYPES = new Set([
|
|||||||
})
|
})
|
||||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||||
params = signal<ContextMenuParams | null>(null);
|
params = signal<ContextMenuParams | null>(null);
|
||||||
|
|
||||||
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
|
||||||
|
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
private readonly customEmoji = inject(CustomEmojiService);
|
private readonly customEmoji = inject(CustomEmojiService);
|
||||||
|
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
|
|
||||||
private cleanup: (() => void) | null = null;
|
private cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||||
|
|
||||||
@HostListener('document:contextmenu', ['$event'])
|
@HostListener('document:contextmenu', ['$event'])
|
||||||
@@ -709,4 +715,5 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
|||||||
return typeof navigator !== 'undefined'
|
return typeof navigator !== 'undefined'
|
||||||
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
|
||||||
<div
|
<div
|
||||||
appThemeNode="titleBar"
|
appThemeNode="titleBar"
|
||||||
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -82,39 +81,37 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
|
|||||||
* Electron-style title bar with window controls, navigation, and server menu.
|
* Electron-style title bar with window controls, navigation, and server menu.
|
||||||
*/
|
*/
|
||||||
export class TitleBarComponent {
|
export class TitleBarComponent {
|
||||||
private readonly appI18n = inject(AppI18nService);
|
|
||||||
private store = inject(Store);
|
|
||||||
private electronBridge = inject(ElectronBridgeService);
|
|
||||||
private serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private router = inject(Router);
|
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
|
||||||
private platform = inject(PlatformService);
|
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
|
||||||
private settingsModal = inject(SettingsModalService);
|
|
||||||
private pluginRegistry = inject(PluginRegistryService);
|
|
||||||
private pluginRequirements = inject(PluginRequirementStateService);
|
|
||||||
private pluginStore = inject(PluginStoreService);
|
|
||||||
|
|
||||||
private getWindowControlsApi() {
|
|
||||||
return this.electronBridge.getApi();
|
|
||||||
}
|
|
||||||
|
|
||||||
isElectron = computed(() => this.platform.isElectron);
|
isElectron = computed(() => this.platform.isElectron);
|
||||||
|
|
||||||
showMenuState = computed(() => false);
|
showMenuState = computed(() => false);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
|
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
|
||||||
|
|
||||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
|
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
|
||||||
|
|
||||||
isConnected = computed(() => this.webrtc.isConnected());
|
isConnected = computed(() => this.webrtc.isConnected());
|
||||||
|
|
||||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||||
|
|
||||||
isAuthed = computed(() => !!this.currentUser());
|
isAuthed = computed(() => !!this.currentUser());
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
|
|
||||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||||
|
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
|
|
||||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||||
|
|
||||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||||
|
|
||||||
isInDirectMessage = toSignal(
|
isInDirectMessage = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -122,6 +119,7 @@ export class TitleBarComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/dm/') }
|
{ initialValue: this.router.url.startsWith('/dm/') }
|
||||||
);
|
);
|
||||||
|
|
||||||
isInRoomView = toSignal(
|
isInRoomView = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
@@ -129,8 +127,11 @@ export class TitleBarComponent {
|
|||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/room/') }
|
{ initialValue: this.router.url.startsWith('/room/') }
|
||||||
);
|
);
|
||||||
|
|
||||||
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
|
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
|
||||||
|
|
||||||
roomName = computed(() => this.currentRoom()?.name || '');
|
roomName = computed(() => this.currentRoom()?.name || '');
|
||||||
|
|
||||||
activeTextChannelName = computed(() => {
|
activeTextChannelName = computed(() => {
|
||||||
const textChannels = this.textChannels();
|
const textChannels = this.textChannels();
|
||||||
|
|
||||||
@@ -143,12 +144,14 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
return activeChannel ? activeChannel.name : id;
|
return activeChannel ? activeChannel.name : id;
|
||||||
});
|
});
|
||||||
|
|
||||||
connectedVoiceChannelName = computed(() => {
|
connectedVoiceChannelName = computed(() => {
|
||||||
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
|
||||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
|
||||||
|
|
||||||
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
|
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
|
||||||
});
|
});
|
||||||
|
|
||||||
roomContextMeta = computed(() => {
|
roomContextMeta = computed(() => {
|
||||||
if (!this.currentRoom()) {
|
if (!this.currentRoom()) {
|
||||||
return '';
|
return '';
|
||||||
@@ -162,9 +165,11 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
return parts.join(' | ');
|
return parts.join(' | ');
|
||||||
});
|
});
|
||||||
|
|
||||||
showRoomCompatibilityNotice = computed(() =>
|
showRoomCompatibilityNotice = computed(() =>
|
||||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||||
);
|
);
|
||||||
|
|
||||||
showRoomReconnectNotice = computed(() =>
|
showRoomReconnectNotice = computed(() =>
|
||||||
this.inRoom()
|
this.inRoom()
|
||||||
&& !this.signalServerCompatibilityError()
|
&& !this.signalServerCompatibilityError()
|
||||||
@@ -174,21 +179,57 @@ export class TitleBarComponent {
|
|||||||
|| this.isReconnecting()
|
|| this.isReconnecting()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
serverPluginCount = computed(() => this.pluginRegistry.entries()
|
||||||
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
|
||||||
.length);
|
.length);
|
||||||
|
|
||||||
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
|
||||||
|
|
||||||
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
|
||||||
|
|
||||||
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
|
||||||
|
|
||||||
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
|
||||||
private _showMenu = signal(false);
|
|
||||||
showMenu = computed(() => this._showMenu());
|
showMenu = computed(() => this._showMenu());
|
||||||
|
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
|
|
||||||
inviteStatus = signal<string | null>(null);
|
inviteStatus = signal<string | null>(null);
|
||||||
|
|
||||||
creatingInvite = signal(false);
|
creatingInvite = signal(false);
|
||||||
|
|
||||||
pluginRequirementBusy = signal(false);
|
pluginRequirementBusy = signal(false);
|
||||||
|
|
||||||
pluginRequirementError = signal<string | null>(null);
|
pluginRequirementError = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
private serverDirectory = inject(ServerDirectoryFacade);
|
||||||
|
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
|
|
||||||
|
private settingsModal = inject(SettingsModalService);
|
||||||
|
|
||||||
|
private pluginRegistry = inject(PluginRegistryService);
|
||||||
|
|
||||||
|
private pluginRequirements = inject(PluginRequirementStateService);
|
||||||
|
|
||||||
|
private pluginStore = inject(PluginStoreService);
|
||||||
|
|
||||||
|
private _showMenu = signal(false);
|
||||||
|
|
||||||
/** Minimize the Electron window. */
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
const api = this.getWindowControlsApi();
|
const api = this.getWindowControlsApi();
|
||||||
@@ -256,15 +297,6 @@ export class TitleBarComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the unified leave-server confirmation dialog. */
|
|
||||||
private openLeaveConfirm() {
|
|
||||||
this._showMenu.set(false);
|
|
||||||
|
|
||||||
if (this.currentRoom()) {
|
|
||||||
this.showLeaveConfirm.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Toggle the server dropdown menu. */
|
/** Toggle the server dropdown menu. */
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
this.inviteStatus.set(null);
|
this.inviteStatus.set(null);
|
||||||
@@ -354,6 +386,34 @@ export class TitleBarComponent {
|
|||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
|
logout() {
|
||||||
|
this._showMenu.set(false);
|
||||||
|
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||||
|
// servers the user was a member of, so other users see them go offline.
|
||||||
|
this.webrtc.disconnect();
|
||||||
|
|
||||||
|
clearStoredCurrentUserId();
|
||||||
|
this.store.dispatch(MessagesActions.clearMessages());
|
||||||
|
this.store.dispatch(RoomsActions.resetRoomsState());
|
||||||
|
this.store.dispatch(UsersActions.resetUsersState());
|
||||||
|
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getWindowControlsApi() {
|
||||||
|
return this.electronBridge.getApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the unified leave-server confirmation dialog. */
|
||||||
|
private openLeaveConfirm() {
|
||||||
|
this._showMenu.set(false);
|
||||||
|
|
||||||
|
if (this.currentRoom()) {
|
||||||
|
this.showLeaveConfirm.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
@@ -373,21 +433,6 @@ export class TitleBarComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
|
||||||
logout() {
|
|
||||||
this._showMenu.set(false);
|
|
||||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
|
||||||
// servers the user was a member of, so other users see them go offline.
|
|
||||||
this.webrtc.disconnect();
|
|
||||||
|
|
||||||
clearStoredCurrentUserId();
|
|
||||||
this.store.dispatch(MessagesActions.clearMessages());
|
|
||||||
this.store.dispatch(RoomsActions.resetRoomsState());
|
|
||||||
this.store.dispatch(UsersActions.resetUsersState());
|
|
||||||
|
|
||||||
this.router.navigate(['/login']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
try {
|
try {
|
||||||
@@ -427,4 +472,5 @@ export class TitleBarComponent {
|
|||||||
sourceUrl: room.sourceUrl
|
sourceUrl: room.sourceUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
types: [
|
types: [
|
||||||
{
|
{
|
||||||
id: 'INCOMING_CALL_ACTIONS',
|
id: 'INCOMING_CALL_ACTIONS',
|
||||||
actions: [{ id: 'answer', title: mobileLabel('mobile.notifications.answer') }, { id: 'hangup', title: mobileLabel('mobile.notifications.decline') }]
|
actions: this.incomingCallNotificationActions()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ACTIVE_CALL_ACTIONS',
|
id: 'ACTIVE_CALL_ACTIONS',
|
||||||
actions: [{ id: 'mute', title: mobileLabel('mobile.notifications.mute') }, { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }]
|
actions: this.activeCallNotificationActions()
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -161,4 +161,18 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
|
||||||
this.actionHandler = handler;
|
this.actionHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private incomingCallNotificationActions() {
|
||||||
|
const answerAction = { id: 'answer', title: mobileLabel('mobile.notifications.answer') };
|
||||||
|
const hangupAction = { id: 'hangup', title: mobileLabel('mobile.notifications.decline') };
|
||||||
|
|
||||||
|
return [answerAction, hangupAction];
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeCallNotificationActions() {
|
||||||
|
const muteAction = { id: 'mute', title: mobileLabel('mobile.notifications.mute') };
|
||||||
|
const hangupAction = { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') };
|
||||||
|
|
||||||
|
return [muteAction, hangupAction];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-conn
|
|||||||
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
|
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
|
||||||
*/
|
*/
|
||||||
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
|
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
|
||||||
private initialized = false;
|
|
||||||
|
|
||||||
constructor(private readonly connection: MobileSqliteConnectionService) {}
|
|
||||||
|
|
||||||
get isNativeSqlite(): boolean {
|
get isNativeSqlite(): boolean {
|
||||||
return this.connection.isAvailable;
|
return this.connection.isAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor(private readonly connection: MobileSqliteConnectionService) {}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
return;
|
return;
|
||||||
@@ -32,4 +33,5 @@ export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapt
|
|||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.info('[mobile] native SQLite persistence initialized');
|
console.info('[mobile] native SQLite persistence initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
|
|||||||
'ALTER TABLE messages ADD COLUMN kind TEXT',
|
'ALTER TABLE messages ADD COLUMN kind TEXT',
|
||||||
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
|
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
|
||||||
];
|
];
|
||||||
const SCHEMA_V3_MESSAGE_COLUMNS = ['ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE messages ADD COLUMN headHash TEXT'];
|
const SCHEMA_V3_REVISION_COLUMN = 'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0';
|
||||||
|
const SCHEMA_V3_HEAD_HASH_COLUMN = 'ALTER TABLE messages ADD COLUMN headHash TEXT';
|
||||||
|
|
||||||
/** Returns DDL statements that still need to run for the stored schema version. */
|
/** Returns DDL statements that still need to run for the stored schema version. */
|
||||||
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
|
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
|
||||||
@@ -157,7 +158,7 @@ export function resolveMobileSqliteMigrationStatements(storedVersion: number): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (storedVersion < 3) {
|
if (storedVersion < 3) {
|
||||||
statements.push(...SCHEMA_V3_MESSAGE_COLUMNS);
|
statements.push(SCHEMA_V3_REVISION_COLUMN, SCHEMA_V3_HEAD_HASH_COLUMN);
|
||||||
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`);
|
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user