feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s
This commit is contained in:
@@ -189,6 +189,14 @@
|
||||
[ringClass]="getVoiceUserRingClass(u)"
|
||||
/>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
<!-- Connectivity warning -->
|
||||
@if (hasConnectivityIssue(u)) {
|
||||
<ng-icon
|
||||
name="lucideAlertTriangle"
|
||||
class="w-3.5 h-3.5 text-amber-500 shrink-0"
|
||||
title="Connection issue - this user may not hear all participants. Consider adding a TURN server in Settings -> Network."
|
||||
/>
|
||||
}
|
||||
<!-- Ping latency indicator -->
|
||||
@if (u.id !== currentUser()?.id) {
|
||||
<span
|
||||
@@ -399,6 +407,15 @@
|
||||
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
||||
@if (localUserHasDesync()) {
|
||||
<div class="mx-2 mb-1 flex items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
||||
<ng-icon
|
||||
name="lucideAlertTriangle"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span>You may have connectivity issues. Adding a TURN server in Settings -> Network may help.</span>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="border-t border-border px-2 py-3"
|
||||
[class.invisible]="showFloatingControls()"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideAlertTriangle,
|
||||
lucideMonitor,
|
||||
lucideVideo,
|
||||
lucideHash,
|
||||
@@ -35,7 +36,11 @@ import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { NotificationsFacade } from '../../../domains/notifications';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection';
|
||||
import {
|
||||
VoiceActivityService,
|
||||
VoiceConnectionFacade,
|
||||
VoiceConnectivityHealthService
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
@@ -83,6 +88,7 @@ type PanelMode = 'channels' | 'users';
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideChevronLeft,
|
||||
lucideAlertTriangle,
|
||||
lucideMonitor,
|
||||
lucideVideo,
|
||||
lucideHash,
|
||||
@@ -104,6 +110,7 @@ export class RoomsSidePanelComponent {
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
readonly showVoiceControls = input(true);
|
||||
@@ -115,6 +122,7 @@ export class RoomsSidePanelComponent {
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
voiceChannels = this.store.selectSignal(selectVoiceChannels);
|
||||
localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
|
||||
roomMembers = computed(() => this.currentRoom()?.members ?? []);
|
||||
roomMemberIdentifiers = computed(() => {
|
||||
const identifiers = new Set<string>();
|
||||
@@ -248,6 +256,10 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
hasConnectivityIssue(user: User): boolean {
|
||||
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
||||
}
|
||||
|
||||
canManageChannels(): boolean {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<section data-testid="ice-server-settings">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ice-restore-defaults"
|
||||
(click)="restoreDefaults()"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRotateCcw"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
Restore Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
|
||||
priority.
|
||||
</p>
|
||||
|
||||
<!-- ICE Server List -->
|
||||
<div
|
||||
class="space-y-2 mb-3"
|
||||
data-testid="ice-server-list"
|
||||
>
|
||||
@for (entry of entries(); track trackEntry($index, entry); let i = $index) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
|
||||
[class.border-blue-500/40]="entry.type === 'turn'"
|
||||
[class.bg-blue-500/5]="entry.type === 'turn'"
|
||||
[class.border-border]="entry.type === 'stun'"
|
||||
[class.bg-secondary/30]="entry.type === 'stun'"
|
||||
[attr.data-testid]="'ice-entry-' + i"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||
[class.bg-muted]="entry.type === 'stun'"
|
||||
[class.text-muted-foreground]="entry.type === 'stun'"
|
||||
[class.bg-blue-500/15]="entry.type === 'turn'"
|
||||
[class.text-blue-400]="entry.type === 'turn'"
|
||||
>
|
||||
{{ entry.type }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
|
||||
@if (entry.type === 'turn' && entry.username) {
|
||||
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
(click)="moveUp(i)"
|
||||
[disabled]="i === 0"
|
||||
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
|
||||
title="Move up (higher priority)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowUp"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="moveDown(i)"
|
||||
[disabled]="i === entries().length - 1"
|
||||
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
|
||||
title="Move down (lower priority)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowDown"
|
||||
class="w-3.5 h-3.5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeEntry(entry.id)"
|
||||
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
|
||||
title="Remove"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (entries().length === 0) {
|
||||
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New ICE Server -->
|
||||
<div class="border-t border-border pt-3">
|
||||
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
[(ngModel)]="newType"
|
||||
data-testid="ice-type-select"
|
||||
class="px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="stun">STUN</option>
|
||||
<option value="turn">TURN</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newUrl"
|
||||
data-testid="ice-url-input"
|
||||
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
|
||||
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
@if (newType === 'turn') {
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newUsername"
|
||||
data-testid="ice-username-input"
|
||||
placeholder="Username"
|
||||
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newCredential"
|
||||
data-testid="ice-credential-input"
|
||||
placeholder="Credential"
|
||||
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ice-add-button"
|
||||
(click)="addEntry()"
|
||||
[disabled]="!newUrl"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideShield,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideArrowUp,
|
||||
lucideArrowDown,
|
||||
lucideRotateCcw
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ice-server-settings',
|
||||
standalone: true,
|
||||
host: {
|
||||
style: 'display: block;'
|
||||
},
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideArrowUp,
|
||||
lucideArrowDown,
|
||||
lucideRotateCcw
|
||||
})
|
||||
],
|
||||
templateUrl: './ice-server-settings.component.html'
|
||||
})
|
||||
export class IceServerSettingsComponent {
|
||||
private iceSettings = inject(IceServerSettingsService);
|
||||
|
||||
entries = this.iceSettings.entries;
|
||||
|
||||
addError = signal<string | null>(null);
|
||||
newType: 'stun' | 'turn' = 'stun';
|
||||
newUrl = '';
|
||||
newUsername = '';
|
||||
newCredential = '';
|
||||
|
||||
addEntry(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
const url = this.newUrl.trim();
|
||||
|
||||
if (!url) {
|
||||
this.addError.set('URL is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
|
||||
|
||||
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
|
||||
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.newType === 'turn' && !this.newUsername.trim()) {
|
||||
this.addError.set('Username is required for TURN servers');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.newType === 'turn' && !this.newCredential.trim()) {
|
||||
this.addError.set('Credential is required for TURN servers');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.entries().some((entry) => entry.urls === url)) {
|
||||
this.addError.set('This URL already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
this.iceSettings.addEntry({
|
||||
type: this.newType,
|
||||
urls: url,
|
||||
...(this.newType === 'turn'
|
||||
? { username: this.newUsername.trim(), credential: this.newCredential.trim() }
|
||||
: {})
|
||||
});
|
||||
|
||||
this.newUrl = '';
|
||||
this.newUsername = '';
|
||||
this.newCredential = '';
|
||||
}
|
||||
|
||||
removeEntry(id: string): void {
|
||||
this.iceSettings.removeEntry(id);
|
||||
}
|
||||
|
||||
moveUp(index: number): void {
|
||||
if (index > 0)
|
||||
this.iceSettings.moveEntry(index, index - 1);
|
||||
}
|
||||
|
||||
moveDown(index: number): void {
|
||||
if (index < this.entries().length - 1)
|
||||
this.iceSettings.moveEntry(index, index + 1);
|
||||
}
|
||||
|
||||
restoreDefaults(): void {
|
||||
this.iceSettings.restoreDefaults();
|
||||
}
|
||||
|
||||
trackEntry(_index: number, entry: IceServerEntry): string {
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
@@ -199,4 +199,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ICE Server Settings (STUN / TURN) -->
|
||||
<app-ice-server-settings />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
@@ -27,7 +28,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
NgIcon,
|
||||
IceServerSettingsComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
[value]="selectedServerId() || ''"
|
||||
(change)="onServerSelect($event)"
|
||||
>
|
||||
<option value="">Select a server…</option>
|
||||
<option value="">Select a server...</option>
|
||||
@for (room of manageableRooms(); track room.id) {
|
||||
<option [value]="room.id">{{ room.name }}</option>
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
[value]="state().preferredVersion || ''"
|
||||
(change)="onVersionChange($event)"
|
||||
>
|
||||
<option value="">Choose a release…</option>
|
||||
<option value="">Choose a release...</option>
|
||||
@for (version of state().availableVersions; track version) {
|
||||
<option [value]="version">{{ version }}</option>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
class="context-menu-item"
|
||||
[disabled]="!params()!.editFlags.canCut"
|
||||
[class.opacity-40]="!params()!.editFlags.canCut"
|
||||
(click)="execCommand('cut')"
|
||||
(pointerdown)="onActionPointerDown($event, 'cut')"
|
||||
(click)="onActionClick($event, 'cut')"
|
||||
>
|
||||
Cut
|
||||
</button>
|
||||
@@ -20,7 +21,8 @@
|
||||
class="context-menu-item"
|
||||
[disabled]="!params()!.editFlags.canCopy"
|
||||
[class.opacity-40]="!params()!.editFlags.canCopy"
|
||||
(click)="execCommand('copy')"
|
||||
(pointerdown)="onActionPointerDown($event, 'copy')"
|
||||
(click)="onActionClick($event, 'copy')"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -29,7 +31,8 @@
|
||||
class="context-menu-item"
|
||||
[disabled]="!params()!.editFlags.canPaste"
|
||||
[class.opacity-40]="!params()!.editFlags.canPaste"
|
||||
(click)="execCommand('paste')"
|
||||
(pointerdown)="onActionPointerDown($event, 'paste')"
|
||||
(click)="onActionClick($event, 'paste')"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
@@ -39,7 +42,8 @@
|
||||
class="context-menu-item"
|
||||
[disabled]="!params()!.editFlags.canSelectAll"
|
||||
[class.opacity-40]="!params()!.editFlags.canSelectAll"
|
||||
(click)="execCommand('selectAll')"
|
||||
(pointerdown)="onActionPointerDown($event, 'selectAll')"
|
||||
(click)="onActionClick($event, 'selectAll')"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
@@ -47,7 +51,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
(click)="execCommand('copy')"
|
||||
(pointerdown)="onActionPointerDown($event, 'copy')"
|
||||
(click)="onActionClick($event, 'copy')"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -60,7 +65,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
(click)="copyLink()"
|
||||
(pointerdown)="onActionPointerDown($event, 'copyLink')"
|
||||
(click)="onActionClick($event, 'copyLink')"
|
||||
>
|
||||
Copy Link
|
||||
</button>
|
||||
@@ -73,7 +79,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item"
|
||||
(click)="copyImage()"
|
||||
(pointerdown)="onActionPointerDown($event, 'copyImage')"
|
||||
(click)="onActionClick($event, 'copyImage')"
|
||||
>
|
||||
Copy Image
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,48 @@ import {
|
||||
Component,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
HostListener,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||
|
||||
type ContextMenuCommand = 'cut' | 'copy' | 'paste' | 'selectAll';
|
||||
type ContextMenuAction = ContextMenuCommand | 'copyLink' | 'copyImage';
|
||||
type TextControlElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
type ContextMenuTarget = TextControlElement | HTMLElement;
|
||||
|
||||
interface ContextMenuSelectionSnapshot {
|
||||
range: Range | null;
|
||||
selectedText: string;
|
||||
selectionDirection: 'forward' | 'backward' | 'none' | null;
|
||||
selectionEnd: number | null;
|
||||
selectionStart: number | null;
|
||||
target: ContextMenuTarget | null;
|
||||
}
|
||||
|
||||
const NON_TEXT_INPUT_TYPES = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'color',
|
||||
'date',
|
||||
'datetime-local',
|
||||
'file',
|
||||
'hidden',
|
||||
'image',
|
||||
'month',
|
||||
'number',
|
||||
'radio',
|
||||
'range',
|
||||
'reset',
|
||||
'submit',
|
||||
'time',
|
||||
'week'
|
||||
]);
|
||||
|
||||
@Component({
|
||||
selector: 'app-native-context-menu',
|
||||
standalone: true,
|
||||
@@ -18,8 +53,29 @@ import type { ContextMenuParams } from '../../../core/platform/electron/electron
|
||||
export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
params = signal<ContextMenuParams | null>(null);
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private cleanup: (() => void) | null = null;
|
||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
onDocumentContextMenu(event: MouseEvent): void {
|
||||
this.captureSelectionSnapshot(event);
|
||||
|
||||
if (this.electronBridge.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = this.buildBrowserContextMenuParams(event);
|
||||
|
||||
if (!params) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.params.set(params);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const api = this.electronBridge.getApi();
|
||||
@@ -34,7 +90,12 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
|| !!incoming.linkURL
|
||||
|| (incoming.mediaType === 'image' && !!incoming.srcURL);
|
||||
|
||||
this.params.set(hasContent ? incoming : null);
|
||||
if (!hasContent) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.params.set(incoming);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,36 +106,514 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
|
||||
close(): void {
|
||||
this.params.set(null);
|
||||
this.selectionSnapshot = null;
|
||||
}
|
||||
|
||||
execCommand(command: string): void {
|
||||
onActionPointerDown(event: PointerEvent, action: ContextMenuAction): void {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void this.runAction(action);
|
||||
}
|
||||
|
||||
onActionClick(event: MouseEvent, action: ContextMenuAction): void {
|
||||
if (event.detail > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void this.runAction(action);
|
||||
}
|
||||
|
||||
private async runAction(action: ContextMenuAction): Promise<void> {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'copyLink':
|
||||
await this.copyLink();
|
||||
break;
|
||||
case 'copyImage':
|
||||
await this.copyImage();
|
||||
break;
|
||||
default:
|
||||
await this.execCommand(action);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async execCommand(command: ContextMenuCommand): Promise<void> {
|
||||
let handled = false;
|
||||
|
||||
switch (command) {
|
||||
case 'copy':
|
||||
handled = await this.copySelection();
|
||||
break;
|
||||
case 'cut':
|
||||
handled = await this.cutSelection();
|
||||
break;
|
||||
case 'paste':
|
||||
handled = await this.pasteSelection();
|
||||
break;
|
||||
case 'selectAll':
|
||||
handled = this.selectAllSelection();
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (api?.contextMenuCommand) {
|
||||
api.contextMenuCommand(command);
|
||||
this.restoreSelectionSnapshot();
|
||||
await api.contextMenuCommand(command);
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
copyLink(): void {
|
||||
private async copyLink(): Promise<void> {
|
||||
const url = this.params()?.linkURL;
|
||||
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).catch(() => {});
|
||||
await this.writeTextToClipboard(url);
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
copyImage(): void {
|
||||
private async copyImage(): Promise<void> {
|
||||
const srcURL = this.params()?.srcURL;
|
||||
const api = this.electronBridge.getApi();
|
||||
|
||||
if (srcURL && api?.copyImageToClipboard) {
|
||||
api.copyImageToClipboard(srcURL).catch(() => {});
|
||||
if (!srcURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
if (api?.copyImageToClipboard) {
|
||||
const copied = await api.copyImageToClipboard(srcURL).catch(() => false);
|
||||
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.copyImageToBrowserClipboard(srcURL);
|
||||
}
|
||||
|
||||
private captureSelectionSnapshot(event: MouseEvent): void {
|
||||
const target = this.getTargetElement(event.target);
|
||||
const textControl = this.resolveTextControlTarget(target);
|
||||
|
||||
if (textControl) {
|
||||
this.selectionSnapshot = this.createTextControlSnapshot(textControl);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = this.document.getSelection();
|
||||
|
||||
this.selectionSnapshot = {
|
||||
range: selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null,
|
||||
selectedText: selection?.toString() ?? '',
|
||||
selectionDirection: null,
|
||||
selectionEnd: null,
|
||||
selectionStart: null,
|
||||
target: this.resolveContentEditableTarget(target) ?? (target instanceof HTMLElement ? target : null)
|
||||
};
|
||||
}
|
||||
|
||||
private createTextControlSnapshot(target: TextControlElement): ContextMenuSelectionSnapshot {
|
||||
return {
|
||||
range: null,
|
||||
selectedText: this.getTextControlSelection(target),
|
||||
selectionDirection: target.selectionDirection,
|
||||
selectionEnd: target.selectionEnd,
|
||||
selectionStart: target.selectionStart,
|
||||
target
|
||||
};
|
||||
}
|
||||
|
||||
private buildBrowserContextMenuParams(event: MouseEvent): ContextMenuParams | null {
|
||||
const target = this.getTargetElement(event.target);
|
||||
const editableTarget = this.resolveEditableTarget(target);
|
||||
const selectionText = this.selectionSnapshot?.selectedText ?? '';
|
||||
const linkURL = this.resolveLinkUrl(target);
|
||||
const srcURL = this.resolveImageUrl(target);
|
||||
const isEditable = !!editableTarget && !this.isDisabledTarget(editableTarget);
|
||||
|
||||
if (!isEditable && !selectionText && !linkURL && !srcURL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
posX: event.clientX,
|
||||
posY: event.clientY,
|
||||
isEditable,
|
||||
selectionText,
|
||||
linkURL,
|
||||
mediaType: srcURL ? 'image' : '',
|
||||
srcURL,
|
||||
editFlags: {
|
||||
canCut: !!selectionText && !!editableTarget && this.canWriteToTarget(editableTarget),
|
||||
canCopy: !!selectionText,
|
||||
canPaste: !!editableTarget && this.canWriteToTarget(editableTarget) && this.canReadClipboard(),
|
||||
canSelectAll: !!editableTarget && !this.isDisabledTarget(editableTarget)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private restoreSelectionSnapshot(): ContextMenuTarget | null {
|
||||
const snapshot = this.selectionSnapshot;
|
||||
|
||||
if (!snapshot?.target || !snapshot.target.isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isTextControl(snapshot.target)) {
|
||||
snapshot.target.focus({ preventScroll: true });
|
||||
|
||||
if (snapshot.selectionStart !== null && snapshot.selectionEnd !== null) {
|
||||
snapshot.target.setSelectionRange(
|
||||
snapshot.selectionStart,
|
||||
snapshot.selectionEnd,
|
||||
snapshot.selectionDirection ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return snapshot.target;
|
||||
}
|
||||
|
||||
if (this.isContentEditableTarget(snapshot.target)) {
|
||||
snapshot.target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const selection = this.document.getSelection();
|
||||
|
||||
if (selection && snapshot.range) {
|
||||
try {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(snapshot.range);
|
||||
} catch {
|
||||
return snapshot.target;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot.target;
|
||||
}
|
||||
|
||||
private async copySelection(): Promise<boolean> {
|
||||
const text = this.selectionSnapshot?.selectedText ?? '';
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.writeTextToClipboard(text);
|
||||
}
|
||||
|
||||
private async cutSelection(): Promise<boolean> {
|
||||
const target = this.restoreSelectionSnapshot();
|
||||
|
||||
if (!target || !this.canWriteToTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = this.selectionSnapshot?.selectedText ?? '';
|
||||
|
||||
if (!text || !(await this.writeTextToClipboard(text))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTextControl(target)) {
|
||||
const selectionStart = target.selectionStart ?? this.selectionSnapshot?.selectionStart ?? 0;
|
||||
const selectionEnd = target.selectionEnd ?? this.selectionSnapshot?.selectionEnd ?? selectionStart;
|
||||
|
||||
target.setRangeText('', selectionStart, selectionEnd, 'start');
|
||||
this.dispatchInputEvent(target);
|
||||
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
const selection = this.document.getSelection();
|
||||
|
||||
if (!selection?.rangeCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
range.deleteContents();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
this.dispatchInputEvent(target);
|
||||
this.selectionSnapshot = {
|
||||
range: range.cloneRange(),
|
||||
selectedText: '',
|
||||
selectionDirection: null,
|
||||
selectionEnd: null,
|
||||
selectionStart: null,
|
||||
target
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async pasteSelection(): Promise<boolean> {
|
||||
const target = this.restoreSelectionSnapshot();
|
||||
|
||||
if (!target || !this.canWriteToTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = await this.readClipboardText();
|
||||
|
||||
if (text === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTextControl(target)) {
|
||||
const selectionStart = target.selectionStart ?? target.value.length;
|
||||
const selectionEnd = target.selectionEnd ?? selectionStart;
|
||||
|
||||
target.setRangeText(text, selectionStart, selectionEnd, 'end');
|
||||
this.dispatchInputEvent(target);
|
||||
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
const selection = this.document.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!selection.rangeCount) {
|
||||
const range = this.document.createRange();
|
||||
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const textNode = this.document.createTextNode(text);
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
this.dispatchInputEvent(target);
|
||||
this.selectionSnapshot = {
|
||||
range: range.cloneRange(),
|
||||
selectedText: '',
|
||||
selectionDirection: null,
|
||||
selectionEnd: null,
|
||||
selectionStart: null,
|
||||
target
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private selectAllSelection(): boolean {
|
||||
const target = this.selectionSnapshot?.target;
|
||||
|
||||
if (!target || !target.isConnected || this.isDisabledTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTextControl(target)) {
|
||||
target.focus({ preventScroll: true });
|
||||
target.select();
|
||||
this.selectionSnapshot = this.createTextControlSnapshot(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.isContentEditableTarget(target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = this.document.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = this.document.createRange();
|
||||
|
||||
target.focus({ preventScroll: true });
|
||||
range.selectNodeContents(target);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
this.selectionSnapshot = {
|
||||
range: range.cloneRange(),
|
||||
selectedText: selection.toString(),
|
||||
selectionDirection: null,
|
||||
selectionEnd: null,
|
||||
selectionStart: null,
|
||||
target
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async writeTextToClipboard(value: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const body = this.document.body;
|
||||
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = this.document.createElement('textarea');
|
||||
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
return this.document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
private async readClipboardText(): Promise<string | null> {
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async copyImageToBrowserClipboard(srcURL: string): Promise<boolean> {
|
||||
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(srcURL);
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
||||
if (!blob.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type || 'image/png']: blob
|
||||
})
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchInputEvent(target: ContextMenuTarget): void {
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
private getTextControlSelection(target: TextControlElement): string {
|
||||
const selectionStart = target.selectionStart ?? 0;
|
||||
const selectionEnd = target.selectionEnd ?? selectionStart;
|
||||
|
||||
return target.value.slice(selectionStart, selectionEnd);
|
||||
}
|
||||
|
||||
private getTargetElement(target: EventTarget | null): Element | null {
|
||||
if (target instanceof Element) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return target instanceof Node ? target.parentElement : null;
|
||||
}
|
||||
|
||||
private resolveEditableTarget(target: Element | null): ContextMenuTarget | null {
|
||||
return this.resolveTextControlTarget(target) ?? this.resolveContentEditableTarget(target);
|
||||
}
|
||||
|
||||
private resolveTextControlTarget(target: Element | null): TextControlElement | null {
|
||||
const textControl = target?.closest('input, textarea');
|
||||
|
||||
if (textControl instanceof HTMLTextAreaElement) {
|
||||
return textControl;
|
||||
}
|
||||
|
||||
if (textControl instanceof HTMLInputElement && !NON_TEXT_INPUT_TYPES.has(textControl.type)) {
|
||||
return textControl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveContentEditableTarget(target: Element | null): HTMLElement | null {
|
||||
const editable = target?.closest('[contenteditable]:not([contenteditable="false"])');
|
||||
|
||||
return editable instanceof HTMLElement && editable.isContentEditable ? editable : null;
|
||||
}
|
||||
|
||||
private resolveLinkUrl(target: Element | null): string {
|
||||
const link = target?.closest('a[href]');
|
||||
|
||||
return link instanceof HTMLAnchorElement ? link.href : '';
|
||||
}
|
||||
|
||||
private resolveImageUrl(target: Element | null): string {
|
||||
const imageTarget = target instanceof HTMLImageElement
|
||||
? target
|
||||
: target?.closest('img');
|
||||
|
||||
return imageTarget instanceof HTMLImageElement
|
||||
? imageTarget.currentSrc || imageTarget.src
|
||||
: '';
|
||||
}
|
||||
|
||||
private isTextControl(target: ContextMenuTarget | null): target is TextControlElement {
|
||||
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
private isContentEditableTarget(target: ContextMenuTarget | null): target is HTMLElement {
|
||||
return target instanceof HTMLElement && target.isContentEditable;
|
||||
}
|
||||
|
||||
private isDisabledTarget(target: ContextMenuTarget | null): boolean {
|
||||
return this.isTextControl(target) ? target.disabled : false;
|
||||
}
|
||||
|
||||
private canWriteToTarget(target: ContextMenuTarget | null): boolean {
|
||||
if (this.isTextControl(target)) {
|
||||
return !target.disabled && !target.readOnly;
|
||||
}
|
||||
|
||||
return this.isContentEditableTarget(target);
|
||||
}
|
||||
|
||||
private canReadClipboard(): boolean {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
name="lucideRefreshCw"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
Reconnecting to signal server…
|
||||
Reconnecting to signal server...
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||
[class.hidden]="!isReconnecting()"
|
||||
>Reconnecting…</span
|
||||
>Reconnecting...</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
@@ -110,7 +110,7 @@
|
||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
@if (creatingInvite()) {
|
||||
Creating Invite Link…
|
||||
Creating Invite Link...
|
||||
} @else {
|
||||
Create Invite Link
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export class TitleBarComponent {
|
||||
}
|
||||
|
||||
this.creatingInvite.set(true);
|
||||
this.inviteStatus.set('Creating invite link…');
|
||||
this.inviteStatus.set('Creating invite link...');
|
||||
|
||||
try {
|
||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||
|
||||
Reference in New Issue
Block a user