- Add peer-to-peer polling with real-time synchronization via PeerJS - Implement vote changing, unvoting, and proper vote validation - Add modal interface for adding poll options - Fix vote inheritance issues for new users joining rooms - Create sidebar with available polls and connected peers - Add vote count synchronization across all connected peers - Implement proper vote state validation per user - Add localStorage persistence for polls and vote states - Create responsive design with modern UI components - Replace emojis with proper text-based icons - Add comprehensive error handling and user feedback - Support multiple polls with switching capabilities - Implement conflict resolution for concurrent voting
429 lines
12 KiB
JavaScript
429 lines
12 KiB
JavaScript
class PollManager {
|
|
constructor(peerManager) {
|
|
this.peerManager = peerManager;
|
|
this.currentPoll = null;
|
|
this.availablePolls = new Map(); // pollId -> poll
|
|
this.myVotes = new Set();
|
|
this.onPollUpdated = null;
|
|
this.onPollCreated = null;
|
|
this.onPollSelected = null;
|
|
this.onPollsListUpdated = null;
|
|
}
|
|
|
|
createPoll(question, options) {
|
|
const poll = {
|
|
id: this.generatePollId(),
|
|
question: question.trim(),
|
|
options: options.map((text, index) => ({
|
|
id: `opt-${index}`,
|
|
text: text.trim(),
|
|
votes: 0,
|
|
voters: []
|
|
})),
|
|
createdBy: this.peerManager.getPeerId(),
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
// Clear any existing votes when creating a new poll
|
|
this.myVotes.clear();
|
|
|
|
this.currentPoll = poll;
|
|
this.availablePolls.set(poll.id, poll);
|
|
this.saveToLocalStorage();
|
|
|
|
if (this.onPollCreated) {
|
|
this.onPollCreated(poll);
|
|
}
|
|
|
|
if (this.onPollsListUpdated) {
|
|
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
|
|
}
|
|
|
|
// Broadcast new poll to all peers
|
|
this.broadcastPollUpdate();
|
|
|
|
return poll;
|
|
}
|
|
|
|
addOption(text) {
|
|
if (!this.currentPoll) {
|
|
throw new Error('No active poll');
|
|
}
|
|
|
|
const newOption = {
|
|
id: `opt-${Date.now()}`,
|
|
text: text.trim(),
|
|
votes: 0,
|
|
voters: []
|
|
};
|
|
|
|
this.currentPoll.options.push(newOption);
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
this.broadcastPollUpdate();
|
|
|
|
return newOption;
|
|
}
|
|
|
|
vote(optionId) {
|
|
if (!this.currentPoll) {
|
|
throw new Error('No active poll');
|
|
}
|
|
|
|
const myPeerId = this.peerManager.getPeerId();
|
|
|
|
// Check if already voted for this option
|
|
if (this.myVotes.has(optionId)) {
|
|
return false;
|
|
}
|
|
|
|
this.removePreviousVote(myPeerId);
|
|
|
|
// Add new vote
|
|
const option = this.currentPoll.options.find(opt => opt.id === optionId);
|
|
if (!option) {
|
|
throw new Error('Option not found');
|
|
}
|
|
|
|
option.votes++;
|
|
option.voters.push(myPeerId);
|
|
this.myVotes.add(optionId);
|
|
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
this.broadcastVote(optionId, myPeerId);
|
|
|
|
return true;
|
|
}
|
|
|
|
removePreviousVote(peerId) {
|
|
if (!this.currentPoll) return;
|
|
|
|
this.currentPoll.options.forEach(option => {
|
|
const voterIndex = option.voters.indexOf(peerId);
|
|
if (voterIndex !== -1) {
|
|
option.votes--;
|
|
option.voters.splice(voterIndex, 1);
|
|
|
|
// Remove from my votes if it's my vote
|
|
if (peerId === this.peerManager.getPeerId()) {
|
|
this.myVotes.delete(option.id);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
resetPoll() {
|
|
if (!this.currentPoll) {
|
|
throw new Error('No active poll');
|
|
}
|
|
|
|
this.currentPoll.options.forEach(option => {
|
|
option.votes = 0;
|
|
option.voters = [];
|
|
});
|
|
|
|
this.myVotes.clear();
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
this.broadcastPollReset();
|
|
}
|
|
|
|
syncPoll(pollData) {
|
|
if (!this.availablePolls.has(pollData.id)) {
|
|
this.availablePolls.set(pollData.id, pollData);
|
|
this.saveToLocalStorage();
|
|
|
|
if (this.onPollsListUpdated) {
|
|
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
|
|
}
|
|
}
|
|
|
|
if (!this.currentPoll || this.currentPoll.id === pollData.id) {
|
|
this.currentPoll = pollData;
|
|
this.availablePolls.set(pollData.id, pollData);
|
|
|
|
this.validateMyVotes(pollData);
|
|
|
|
this.saveToLocalStorage();
|
|
if (this.onPollCreated) {
|
|
this.onPollCreated(pollData);
|
|
}
|
|
} else if (this.currentPoll.id === pollData.id) {
|
|
this.mergePollData(pollData);
|
|
this.availablePolls.set(pollData.id, this.currentPoll);
|
|
|
|
this.validateMyVotes(this.currentPoll);
|
|
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
}
|
|
}
|
|
|
|
mergePollData(remotePoll) {
|
|
const localOptions = new Map(this.currentPoll.options.map(opt => [opt.id, opt]));
|
|
const remoteOptions = new Map(remotePoll.options.map(opt => [opt.id, opt]));
|
|
|
|
remoteOptions.forEach((remoteOpt, id) => {
|
|
if (!localOptions.has(id)) {
|
|
this.currentPoll.options.push(remoteOpt);
|
|
localOptions.set(id, remoteOpt);
|
|
}
|
|
});
|
|
|
|
this.currentPoll.options.forEach(localOpt => {
|
|
const remoteOpt = remoteOptions.get(localOpt.id);
|
|
if (remoteOpt) {
|
|
const allVoters = new Set([...localOpt.voters, ...remoteOpt.voters]);
|
|
localOpt.voters = Array.from(allVoters);
|
|
localOpt.votes = localOpt.voters.length;
|
|
}
|
|
});
|
|
}
|
|
|
|
handleVoteMessage(optionId, voterId) {
|
|
if (!this.currentPoll) return;
|
|
|
|
this.removePreviousVote(voterId);
|
|
|
|
const option = this.currentPoll.options.find(opt => opt.id === optionId);
|
|
if (option && !option.voters.includes(voterId)) {
|
|
option.votes++;
|
|
option.voters.push(voterId);
|
|
|
|
// Update my votes if this is my vote
|
|
if (voterId === this.peerManager.getPeerId()) {
|
|
this.myVotes.add(optionId);
|
|
}
|
|
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
}
|
|
}
|
|
|
|
handleUnvoteMessage(voterId) {
|
|
if (!this.currentPoll) return;
|
|
|
|
// Remove vote from this voter
|
|
this.removePreviousVote(voterId);
|
|
|
|
// Update my votes if this is my vote
|
|
if (voterId === this.peerManager.getPeerId()) {
|
|
this.myVotes.clear();
|
|
}
|
|
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
}
|
|
|
|
handlePollReset() {
|
|
if (!this.currentPoll) return;
|
|
|
|
this.currentPoll.options.forEach(option => {
|
|
option.votes = 0;
|
|
option.voters = [];
|
|
});
|
|
|
|
this.myVotes.clear();
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
}
|
|
|
|
broadcastPollUpdate() {
|
|
if (!this.currentPoll) return;
|
|
|
|
this.peerManager.broadcastMessage({
|
|
type: 'poll_update',
|
|
poll: this.currentPoll,
|
|
senderId: this.peerManager.getPeerId()
|
|
});
|
|
}
|
|
|
|
broadcastVote(optionId, voterId) {
|
|
this.peerManager.broadcastMessage({
|
|
type: 'vote',
|
|
optionId: optionId,
|
|
voterId: voterId,
|
|
senderId: this.peerManager.getPeerId()
|
|
});
|
|
}
|
|
|
|
broadcastPollReset() {
|
|
this.peerManager.broadcastMessage({
|
|
type: 'poll_reset',
|
|
senderId: this.peerManager.getPeerId()
|
|
});
|
|
}
|
|
|
|
selectPoll(pollId) {
|
|
const poll = this.availablePolls.get(pollId);
|
|
if (poll) {
|
|
this.currentPoll = poll;
|
|
|
|
// Clear votes when switching to a different poll
|
|
// Only clear if this is a different poll than what we had voted in
|
|
const currentVotedOption = this.getMyVotedOption();
|
|
if (currentVotedOption && !poll.options.some(opt => opt.id === currentVotedOption)) {
|
|
this.myVotes.clear();
|
|
}
|
|
|
|
this.saveToLocalStorage();
|
|
|
|
if (this.onPollSelected) {
|
|
this.onPollSelected(poll);
|
|
}
|
|
|
|
if (this.onPollUpdated) {
|
|
this.onPollUpdated(poll);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
createNewPoll() {
|
|
this.currentPoll = null;
|
|
// Clear current poll inputs but keep available polls
|
|
if (this.onPollSelected) {
|
|
this.onPollSelected(null);
|
|
}
|
|
}
|
|
|
|
getAvailablePolls() {
|
|
return Array.from(this.availablePolls.values());
|
|
}
|
|
|
|
unvote() {
|
|
if (!this.currentPoll) {
|
|
throw new Error('No active poll');
|
|
}
|
|
|
|
const myPeerId = this.peerManager.getPeerId();
|
|
const myVotedOption = this.getMyVotedOption();
|
|
|
|
if (!myVotedOption) {
|
|
return false; // No vote to remove
|
|
}
|
|
|
|
// Remove vote from current option
|
|
this.removePreviousVote(myPeerId);
|
|
|
|
this.saveToLocalStorage();
|
|
this.notifyPollUpdated();
|
|
this.broadcastUnvote(myPeerId);
|
|
|
|
return true;
|
|
}
|
|
|
|
validateMyVotes(poll) {
|
|
const myPeerId = this.peerManager.getPeerId();
|
|
const validVotes = new Set();
|
|
|
|
console.log('Validating votes for peer:', myPeerId);
|
|
console.log('Current myVotes:', Array.from(this.myVotes));
|
|
console.log('Poll voters:', poll.options.map(opt => ({ id: opt.id, voters: opt.voters })));
|
|
|
|
// Check each vote in myVotes to see if it's actually mine
|
|
this.myVotes.forEach(optionId => {
|
|
const option = poll.options.find(opt => opt.id === optionId);
|
|
if (option && option.voters.includes(myPeerId)) {
|
|
// This vote is actually mine
|
|
validVotes.add(optionId);
|
|
console.log(`Vote ${optionId} is valid for peer ${myPeerId}`);
|
|
} else {
|
|
console.log(`Vote ${optionId} is NOT valid for peer ${myPeerId}`);
|
|
}
|
|
});
|
|
|
|
// Replace myVotes with only the valid votes
|
|
this.myVotes = validVotes;
|
|
console.log('Final valid votes:', Array.from(this.myVotes));
|
|
}
|
|
|
|
getCurrentPoll() {
|
|
return this.currentPoll;
|
|
}
|
|
|
|
broadcastUnvote(peerId) {
|
|
this.peerManager.broadcastMessage({
|
|
type: 'unvote',
|
|
voterId: peerId,
|
|
senderId: this.peerManager.getPeerId()
|
|
});
|
|
}
|
|
|
|
hasVoted(optionId) {
|
|
return this.myVotes.has(optionId);
|
|
}
|
|
|
|
getMyVotedOption() {
|
|
return Array.from(this.myVotes)[0] || null;
|
|
}
|
|
|
|
generatePollId() {
|
|
return `poll-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
saveToLocalStorage() {
|
|
// Save all available polls
|
|
const pollsData = Array.from(this.availablePolls.values());
|
|
localStorage.setItem('p2p-polls-list', JSON.stringify(pollsData));
|
|
|
|
// Save current poll separately
|
|
if (this.currentPoll) {
|
|
localStorage.setItem('p2p-poll-current', JSON.stringify(this.currentPoll));
|
|
} else {
|
|
localStorage.removeItem('p2p-poll-current');
|
|
}
|
|
|
|
localStorage.setItem('p2p-poll-my-votes', JSON.stringify(Array.from(this.myVotes)));
|
|
}
|
|
|
|
loadFromLocalStorage() {
|
|
try {
|
|
const savedPollsList = localStorage.getItem('p2p-polls-list');
|
|
const savedVotes = localStorage.getItem('p2p-poll-my-votes');
|
|
|
|
if (savedPollsList) {
|
|
const polls = JSON.parse(savedPollsList);
|
|
this.availablePolls.clear();
|
|
polls.forEach(poll => {
|
|
this.availablePolls.set(poll.id, poll);
|
|
});
|
|
|
|
if (this.onPollsListUpdated) {
|
|
this.onPollsListUpdated(polls);
|
|
}
|
|
}
|
|
|
|
if (savedVotes) {
|
|
this.myVotes = new Set(JSON.parse(savedVotes));
|
|
|
|
// Validate votes against current poll
|
|
const currentPoll = this.currentPoll || this.availablePolls.values().next().value;
|
|
if (currentPoll) {
|
|
this.validateMyVotes(currentPoll);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load from localStorage:', error);
|
|
}
|
|
}
|
|
|
|
clearData() {
|
|
this.currentPoll = null;
|
|
this.availablePolls.clear();
|
|
this.myVotes.clear();
|
|
localStorage.removeItem('p2p-poll-current');
|
|
localStorage.removeItem('p2p-polls-list');
|
|
localStorage.removeItem('p2p-poll-my-votes');
|
|
}
|
|
|
|
notifyPollUpdated() {
|
|
if (this.onPollUpdated) {
|
|
this.onPollUpdated(this.currentPoll);
|
|
}
|
|
}
|
|
}
|