Files
ChatApp/apps/desktop/src/lib/conversationFeatures.ts
T
byGalax d10840e0b2 feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker
Wires mini-game entry points: createGamePayload helper in conversationFeatures.ts (GamePayload import added), game state/handler/picker-dialog/modal-mount/open-event-listener in ConversationPage.tsx (DM-only guard uses conversation.members[].userId camelCase as confirmed), and parsed.kind==='game' bubble branch in MessageBubble.tsx dispatching chatapp:open-game CustomEvent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:58:10 +02:00

161 lines
4.2 KiB
TypeScript

import {
parseMessagePayload,
serializeMessagePayload,
type AttachmentHandle,
type DecryptedMessage,
type PollOption,
type WhiteboardPayload,
type WatchTogetherPayload,
type GamePayload,
} 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 createWhiteboardPayload(whiteboardId: string): string {
const payload: WhiteboardPayload = {
v: 1,
type: 'whiteboard',
whiteboard_id: whiteboardId,
};
return serializeMessagePayload(payload);
}
export function createWatchTogetherPayload(sessionId: string): string {
const payload: WatchTogetherPayload = {
v: 1,
type: 'watch_together',
session_id: sessionId,
};
return serializeMessagePayload(payload);
}
export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string {
const payload: GamePayload = {
v: 1,
type: 'game',
game_id: gameId,
game_type: gameType,
};
return serializeMessagePayload(payload);
}
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';
}