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:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user