feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)

Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
@@ -0,0 +1,129 @@
import {
parseMessagePayload,
serializeMessagePayload,
type AttachmentHandle,
type DecryptedMessage,
type PollOption,
} from '@chat-app/shared/chat';
export type AttachmentBucket = 'media' | 'audio' | 'files';
export interface ConversationAttachmentItem {
messageId: string;
senderId: string;
createdAt: string;
handle: AttachmentHandle;
bucket: AttachmentBucket;
}
export interface ConversationAttachmentIndex {
all: ConversationAttachmentItem[];
media: ConversationAttachmentItem[];
audio: ConversationAttachmentItem[];
files: ConversationAttachmentItem[];
}
interface ReactionSummaryInput {
emoji: string;
count: number;
mine: boolean;
}
export interface PollVoteOptionSummary extends PollOption {
count: number;
mine: boolean;
percent: number;
}
export interface PollVoteSummary {
totalVotes: number;
options: PollVoteOptionSummary[];
}
export const POLL_OPTION_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'];
export function collectConversationAttachments(
messages: DecryptedMessage[],
): ConversationAttachmentIndex {
const all: ConversationAttachmentItem[] = [];
for (const message of messages) {
if (message.deletedAt || !message.plaintext) continue;
const parsed = parseMessagePayload(message.plaintext);
if (parsed.kind !== 'text') continue;
for (const handle of parsed.attachments) {
all.push({
messageId: message.id,
senderId: message.senderId,
createdAt: message.createdAt,
handle,
bucket: bucketForMime(handle.mimeType),
});
}
}
all.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
return {
all,
media: all.filter((item) => item.bucket === 'media'),
audio: all.filter((item) => item.bucket === 'audio'),
files: all.filter((item) => item.bucket === 'files'),
};
}
export function createPollPayload(question: string, optionTexts: string[]): string {
const trimmedQuestion = question.trim();
if (!trimmedQuestion) throw new Error('poll question is required');
const options: PollOption[] = optionTexts
.map((text) => text.trim())
.filter(Boolean)
.slice(0, POLL_OPTION_EMOJIS.length)
.map((text, idx) => ({
id: 'option-' + (idx + 1),
emoji: POLL_OPTION_EMOJIS[idx]!,
text,
}));
if (options.length < 2) throw new Error('poll requires at least two options');
return serializeMessagePayload({
v: 1,
type: 'poll',
question: trimmedQuestion,
options,
});
}
export function summarizePollVotes(
options: PollOption[],
reactions: ReactionSummaryInput[],
): PollVoteSummary {
const reactionByEmoji = new Map(reactions.map((reaction) => [reaction.emoji, reaction]));
const totalVotes = options.reduce(
(sum, option) => sum + (reactionByEmoji.get(option.emoji)?.count ?? 0),
0,
);
return {
totalVotes,
options: options.map((option) => {
const reaction = reactionByEmoji.get(option.emoji);
const count = reaction?.count ?? 0;
return {
...option,
count,
mine: reaction?.mine ?? false,
percent: totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0,
};
}),
};
}
function bucketForMime(mimeType: string): AttachmentBucket {
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) return 'media';
if (mimeType.startsWith('audio/')) return 'audio';
return 'files';
}