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

This commit is contained in:
2026-04-18 21:27:04 +02:00
parent 167c45ba8d
commit 44588e8789
60 changed files with 2404 additions and 365 deletions

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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(