d10840e0b2
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>
161 lines
4.2 KiB
TypeScript
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';
|
|
}
|