From 6840072ec97a6f095698bb041283c346b2adc532 Mon Sep 17 00:00:00 2001 From: QH Date: Sat, 27 Jun 2026 22:18:00 +0800 Subject: [PATCH] feat(messenger-poll): implement poll creation UI with React Native bottom sheet --- packages/messenger-poll-ui/package.json | 25 ++ .../src/components/PollCreationModal.tsx | 268 ++++++++++++++++++ packages/messenger-poll-ui/src/types/index.ts | 42 +++ packages/messenger-poll-ui/tsconfig.json | 21 ++ 4 files changed, 356 insertions(+) create mode 100644 packages/messenger-poll-ui/package.json create mode 100644 packages/messenger-poll-ui/src/components/PollCreationModal.tsx create mode 100644 packages/messenger-poll-ui/src/types/index.ts create mode 100644 packages/messenger-poll-ui/tsconfig.json diff --git a/packages/messenger-poll-ui/package.json b/packages/messenger-poll-ui/package.json new file mode 100644 index 00000000..9a930c88 --- /dev/null +++ b/packages/messenger-poll-ui/package.json @@ -0,0 +1,25 @@ +{ + "name": "@warpspeed/messenger-poll-ui", + "version": "0.1.0", + "description": "Group Chat Poll Creation & Voting UI for warpSpeed Messenger", + "private": true, + "scripts": { + "build": "tsc", + "lint": "eslint src/ --ext .ts,.tsx", + "storybook": "storybook dev -p 6007", + "build-storybook": "storybook build" + }, + "dependencies": { + "react": "^18.3.1", + "react-native": "^0.74.3", + "react-native-reanimated": "^3.14.0", + "@gorhom/bottom-sheet": "^4.6.3", + "react-native-safe-area-context": "^4.10.1" + }, + "devDependencies": { + "@storybook/addon-actions": "^8.1.10", + "@storybook/react-native": "^8.1.10", + "@types/react": "^18.3.3", + "typescript": "^5.5.3" + } +} diff --git a/packages/messenger-poll-ui/src/components/PollCreationModal.tsx b/packages/messenger-poll-ui/src/components/PollCreationModal.tsx new file mode 100644 index 00000000..a728fccc --- /dev/null +++ b/packages/messenger-poll-ui/src/components/PollCreationModal.tsx @@ -0,0 +1,268 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + FlatList, + StyleSheet, + Switch, +} from 'react-native'; +import { PollCreationData, VoteType, MAX_POLL_OPTIONS, MAX_QUESTION_LENGTH } from '../types'; + +interface PollCreationModalProps { + visible: boolean; + onClose: () => void; + onSubmit: (pollData: PollCreationData) => void; +} + +/** + * Poll creation form accessible from the group chat input action/arrow menu. + */ +export const PollCreationModal: React.FC = ({ + visible, + onClose, + onSubmit, +}) => { + const [question, setQuestion] = useState(''); + const [options, setOptions] = useState(['', '']); + const [voteType, setVoteType] = useState('single'); + + const handleAddOption = useCallback(() => { + if (options.length < MAX_POLL_OPTIONS) { + setOptions((prev) => [...prev, '']); + } + }, [options.length]); + + const handleRemoveOption = useCallback((index: number) => { + if (options.length > 2) { + setOptions((prev) => prev.filter((_, i) => i !== index)); + } + }, [options.length]); + + const handleOptionChange = useCallback((index: number, text: string) => { + setOptions((prev) => { + const updated = [...prev]; + updated[index] = text; + return updated; + }); + }, []); + + const handleSubmit = useCallback(() => { + if (!question.trim()) return; + const nonEmptyOptions = options.filter((opt) => opt.trim().length > 0); + if (nonEmptyOptions.length < 2) return; + + onSubmit({ + question: question.trim(), + options: nonEmptyOptions, + voteType, + }); + + // Reset form + setQuestion(''); + setOptions(['', '']); + setVoteType('single'); + onClose(); + }, [question, options, voteType, onSubmit, onClose]); + + if (!visible) return null; + + return ( + + + {/* Header */} + + + Cancel + + Create Poll + + o.trim()).length < 2) && + styles.sendTextDisabled, + ]} + > + Send + + + + + {/* Question Input */} + + + + {question.length}/{MAX_QUESTION_LENGTH} + + + + {/* Options */} + Options + `option-${index}`} + renderItem={({ item, index }) => ( + + handleOptionChange(index, text)} + maxLength={100} + /> + {options.length > 2 && ( + handleRemoveOption(index)} + > + + + )} + + )} + scrollEnabled={false} + /> + + {/* Add Option Button */} + {options.length < MAX_POLL_OPTIONS && ( + + + Add Option + + )} + + {/* Vote Type Toggle */} + + Allow multiple choices + setVoteType(val ? 'multiple' : 'single')} + trackColor={{ false: '#E5E5EA', true: '#007AFF' }} + /> + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + container: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingHorizontal: 20, + paddingBottom: 40, + maxHeight: '80%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#E5E5EA', + }, + cancelText: { + fontSize: 16, + color: '#8E8E93', + }, + title: { + fontSize: 17, + fontWeight: '600', + color: '#1C1C1E', + }, + sendText: { + fontSize: 16, + fontWeight: '600', + color: '#007AFF', + }, + sendTextDisabled: { + color: '#C7C7CC', + }, + questionContainer: { + marginTop: 16, + }, + questionInput: { + fontSize: 16, + color: '#1C1C1E', + paddingVertical: 8, + minHeight: 48, + borderBottomWidth: 1, + borderBottomColor: '#E5E5EA', + }, + charCount: { + fontSize: 12, + color: '#8E8E93', + textAlign: 'right', + marginTop: 4, + }, + sectionLabel: { + fontSize: 13, + fontWeight: '600', + color: '#8E8E93', + textTransform: 'uppercase', + marginTop: 20, + marginBottom: 8, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + optionInput: { + flex: 1, + fontSize: 15, + color: '#1C1C1E', + backgroundColor: '#F2F2F7', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + }, + removeButton: { + marginLeft: 8, + padding: 4, + }, + removeIcon: { + fontSize: 16, + color: '#FF3B30', + }, + addOptionButton: { + paddingVertical: 10, + alignItems: 'center', + backgroundColor: '#F2F2F7', + borderRadius: 8, + marginTop: 4, + }, + addOptionText: { + fontSize: 15, + color: '#007AFF', + fontWeight: '500', + }, + voteTypeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 8, + }, +}); + +export default PollCreationModal; diff --git a/packages/messenger-poll-ui/src/types/index.ts b/packages/messenger-poll-ui/src/types/index.ts new file mode 100644 index 00000000..11fa68ec --- /dev/null +++ b/packages/messenger-poll-ui/src/types/index.ts @@ -0,0 +1,42 @@ +// Types for Messenger Poll feature + +export type VoteType = 'single' | 'multiple'; + +export interface PollOption { + id: string; + text: string; + voters: string[]; // user IDs + createdAt: string; +} + +export interface Poll { + id: string; + question: string; + options: PollOption[]; + voteType: VoteType; + createdBy: string; + createdAt: string; + expiresAt?: string; + totalParticipants: number; // total group members who can vote +} + +export interface PollCreationData { + question: string; + options: string[]; + voteType: VoteType; +} + +export interface VoterProfile { + id: string; + name: string; + avatar?: string; +} + +export const MAX_POLL_OPTIONS = 12; +export const MAX_QUESTION_LENGTH = 255; + +// Helper functions +export function calculatePercentage(votes: number, totalVoters: number): number { + if (totalVoters === 0) return 0; + return Math.round((votes / totalVoters) * 100); +} diff --git a/packages/messenger-poll-ui/tsconfig.json b/packages/messenger-poll-ui/tsconfig.json new file mode 100644 index 00000000..8be5eadf --- /dev/null +++ b/packages/messenger-poll-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ES2022"], + "jsx": "react-jsx", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}