Compare commits
7 Commits
main
...
78d872c83e
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d872c83e | |||
| 5f36ff0b7d | |||
| 424799692a | |||
| 5ecb5f1076 | |||
| 043e813864 | |||
| 2abc0f8930 | |||
|
|
32e39384d5 |
57
README.md
57
README.md
@@ -1,56 +1 @@
|
||||
# Polly - P2P Poll App
|
||||
|
||||
A lightweight, real-time collaborative polling application that uses [Yjs](https://yjs.dev/) and [WebRTC](https://de.wikipedia.org/wiki/WebRTC) to allow multiple users to create options, vote, and see live results without a centralized database or back-end server.
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Real-time Collaboration: Instant synchronization of poll titles, options, and votes across all connected peers using [CRDTs (Conflict-free Replicated Data Types)](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type).
|
||||
- P2P Connectivity: Uses WebRTC via [y-webrtc](https://github.com/yjs/y-webrtc) for direct browser-to-browser communication.
|
||||
- Dynamic Voting: * Add new options on the fly.
|
||||
- Live-updating progress bars and vote tallies.
|
||||
- Automatic sorting of options by vote count.
|
||||
- Voting Deadline: A shared countdown timer (2 minutes) that locks the poll for all participants once expired.
|
||||
- Awareness & Presence: A status bar showing connection health and the number of active peers currently in the room.
|
||||
- Local Persistence: Uses [y-indexeddb](https://github.com/yjs/y-indexeddb) to save the poll state locally in your browser, ensuring data isn't lost if you refresh or lose connection.
|
||||
- No Setup Required: Unique "rooms" are created via URL parameters, making it easy to share a link and start a poll instantly.
|
||||
|
||||
### 🛠 Tech Stack
|
||||
|
||||
- Language: TypeScript
|
||||
- State Management: Yjs (Shared data types: Y.Doc, Y.Map, Y.Text)
|
||||
- Networking: y-webrtc (WebRTC provider for Yjs)
|
||||
- UI: Vanilla DOM manipulation (No heavy frameworks like React or Vue)
|
||||
|
||||
### 💡 How It Works
|
||||
|
||||
- Room Creation: When you open the app, it checks for a ?room= parameter. If none exists, it generates a unique ID and updates the URL.
|
||||
- State Synchronization: The y-webrtc provider connects users with the same room ID. Any change to sync.options or sync.votes is propagated to all users.
|
||||
- Local Reactivity: Components use .observe() and .observeDeep() on Yjs types to trigger a re-render of the UI whenever the shared state changes.
|
||||
- Voting: Votes are stored in a Y.Map<string> where the key is the User ID and the value is the Option ID. This ensures each user can only have one active vote at a time.
|
||||
|
||||
You can simulate a second user by opening an incognito Tab.
|
||||
|
||||
### 🔧 Installation, Development and Deployment
|
||||
|
||||
- Install dependencies:
|
||||
|
||||
```npm install yjs y-webrtc y-indexeddb```
|
||||
|
||||
- Development:
|
||||
|
||||
```npm run dev```
|
||||
|
||||
- Deployment:
|
||||
- The code currently uses an Y-Webrtc-Signaling-Server at localhost:4444 that starts with `npm run dev` for development.
|
||||
- To deploy the App, you need to set up a publicly available signaling server and set the address in the `synx.ts`. E.g. with Docker using the [funnyzak/y-webrtc-signaling](https://hub.docker.com/r/funnyzak/y-webrtc-signaling) image:
|
||||
|
||||
```version: '3.1'
|
||||
services:
|
||||
y-webrtc-signaling:
|
||||
container_name: y-webrtc-signaling
|
||||
image: funnyzak/y-webrtc-signaling:latest
|
||||
restart: always
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "4444:4444"
|
||||
dns: 8.8.8.8```
|
||||
# P2P Poll App
|
||||
114
src/app.ts
114
src/app.ts
@@ -1,19 +1,21 @@
|
||||
import { getUserId } from "./identity";
|
||||
import {
|
||||
User,
|
||||
addOption,
|
||||
toggleVote,
|
||||
deleteOption,
|
||||
setDeadline,
|
||||
clearDeadline,
|
||||
createViewModel,
|
||||
getDeadline,
|
||||
} from "./state";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { initSync } from "./sync";
|
||||
import { initUserSync } from "./userSync";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
import { PollTitle } from "./components/PollTitle";
|
||||
import { AddOption } from "./components/AddOption";
|
||||
import { PollList } from "./components/PollList";
|
||||
import { ShareSection } from "./components/ShareSection";
|
||||
import { DeadlineTimer } from "./components/DeadlineTimer";
|
||||
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto";
|
||||
|
||||
const ROOM_PARAM = "room";
|
||||
|
||||
@@ -39,8 +41,9 @@ function ensureRoomId(): string {
|
||||
|
||||
export function initApp(container: HTMLElement): () => void {
|
||||
const roomId = ensureRoomId();
|
||||
const userId = getUserId();
|
||||
const sync = initSync(roomId);
|
||||
const userSync = initUserSync();
|
||||
let user : User | undefined = undefined;
|
||||
|
||||
const shareUrl = window.location.href;
|
||||
|
||||
@@ -48,17 +51,12 @@ export function initApp(container: HTMLElement): () => void {
|
||||
|
||||
const actions = {
|
||||
addOption: (label: string) => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
if (vm.votingClosed) return;
|
||||
return addOption(sync.options, label, userId);
|
||||
if (!user || isVotingClosed()) return;
|
||||
return addOption(sync.options, label, user.userid);
|
||||
},
|
||||
toggleVote: (optionId: string) => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
if (vm.votingClosed) return;
|
||||
toggleVote(sync.votes, userId, optionId);
|
||||
},
|
||||
deleteOption: (optionId: string) => {
|
||||
deleteOption(sync.options, sync.votes, optionId);
|
||||
if (!user || isVotingClosed()) return;
|
||||
toggleVote(sync.votes, user.userid, optionId);
|
||||
},
|
||||
startDeadline: (durationMs: number) => {
|
||||
setDeadline(sync.deadlineMap, durationMs);
|
||||
@@ -66,20 +64,79 @@ export function initApp(container: HTMLElement): () => void {
|
||||
clearDeadline: () => {
|
||||
clearDeadline(sync.deadlineMap);
|
||||
},
|
||||
onLoginLogout: async (event: Event) => {
|
||||
if(user){
|
||||
user = undefined
|
||||
return false;
|
||||
} else {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
const content = await file.text();
|
||||
console.log("File loaded: ");
|
||||
if (file.name && content) {
|
||||
try {
|
||||
const uuid = file.name.replace(".pem", "");
|
||||
// Standardize the string for the importer
|
||||
const pkBase64 = content.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
||||
|
||||
const key = await stringToCryptoKey(pkBase64, "private");
|
||||
|
||||
user = {
|
||||
userid: uuid,
|
||||
private_key: key,
|
||||
public_key: undefined, // Note: You might need to import a pub key too!
|
||||
};
|
||||
|
||||
console.log("Login successful for:", uuid);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Crypto Import Error:", err);
|
||||
alert("The file content is not a valid Private Key.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to read file", e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onCreateUser: async (event: Event) => {
|
||||
try {
|
||||
const keypair = await generateUserKeyPair();
|
||||
|
||||
console.log('keypair:', keypair);
|
||||
const uuid = uuidv4();
|
||||
user = {
|
||||
userid: uuid,
|
||||
private_key: keypair.privateKey,
|
||||
public_key: keypair.publicKey,
|
||||
};
|
||||
|
||||
const prvKeyString = await exportPrivateKey(keypair.privateKey);
|
||||
|
||||
savePrivateKeyToFile(prvKeyString,uuid+".pem")
|
||||
|
||||
|
||||
const pubKeyString = await exportPublicKey(keypair.publicKey);
|
||||
|
||||
userSync.users.set(user.userid,pubKeyString)
|
||||
return true;
|
||||
} catch (err) {
|
||||
user = undefined
|
||||
console.error("Failed to create new User!", err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function getViewModelParams() {
|
||||
return {
|
||||
yTitle: sync.yTitle,
|
||||
options: sync.options,
|
||||
votes: sync.votes,
|
||||
deadlineMap: sync.deadlineMap,
|
||||
roomId,
|
||||
shareUrl,
|
||||
connectionStatus: sync.getConnectionStatus(),
|
||||
peerCount: sync.getPeerCount(),
|
||||
userId,
|
||||
};
|
||||
function isVotingClosed() {
|
||||
const deadline = getDeadline(sync.deadlineMap);
|
||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
||||
return votingClosed;
|
||||
}
|
||||
|
||||
// --- Build UI ---
|
||||
@@ -99,7 +156,7 @@ export function initApp(container: HTMLElement): () => void {
|
||||
<span>Polly</span>
|
||||
`;
|
||||
|
||||
const statusBar = StatusBar(sync.provider);
|
||||
const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
|
||||
header.append(wordmark, statusBar);
|
||||
|
||||
// Main card
|
||||
@@ -112,10 +169,7 @@ export function initApp(container: HTMLElement): () => void {
|
||||
if (result && !result.ok) return result.error;
|
||||
return null;
|
||||
});
|
||||
const pollList = PollList(sync.options, sync.votes, userId, () => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
return vm.votingClosed;
|
||||
}, actions.toggleVote, actions.deleteOption);
|
||||
const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote);
|
||||
const deadlineTimer = DeadlineTimer(
|
||||
sync.deadlineMap,
|
||||
actions.startDeadline,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Y from "yjs";
|
||||
import { getDeadline } from "../state";
|
||||
import { enforceAppendOnly } from "../yDocUtil";
|
||||
|
||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
@@ -79,7 +80,7 @@ export function DeadlineTimer(
|
||||
onClearDeadline();
|
||||
});
|
||||
|
||||
deadlineMap.observe(() => render());
|
||||
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
|
||||
render();
|
||||
|
||||
return wrapper;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as Y from "yjs";
|
||||
import type { OptionRecord } from "../state";
|
||||
import { PollOption } from "./PollOption";
|
||||
import { enforceAppendOnly } from "../yDocUtil";
|
||||
import { User } from "../state";
|
||||
|
||||
export function PollList(
|
||||
yOptions: Y.Map<OptionRecord>,
|
||||
yVotes: Y.Map<string>,
|
||||
userId: string,
|
||||
user: User | undefined,
|
||||
isVotingClosed: () => boolean,
|
||||
onVote: (optionId: string) => void,
|
||||
onDelete: (optionId: string) => void,
|
||||
): HTMLElement {
|
||||
|
||||
var currentOptions : { [x: string]: any; } | undefined = undefined
|
||||
var currentVotes : { [x: string]: any; } | undefined = undefined
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "poll-list-wrapper";
|
||||
|
||||
@@ -41,25 +46,32 @@ export function PollList(
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
}> = [];
|
||||
if (currentOptions && currentVotes){
|
||||
|
||||
// Tally votes per option
|
||||
const tally = new Map<string, number>();
|
||||
for (const optionId of yVotes.values()) {
|
||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||
// Tally votes per option
|
||||
const tally = new Map<string, number>();
|
||||
for (const optionId of Object.values(currentVotes)) {
|
||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let myVote = null;
|
||||
if (user) {
|
||||
myVote = currentVotes[user.userid]
|
||||
}
|
||||
|
||||
Object.entries(currentOptions).forEach(([id,record]) => {
|
||||
console.log(`${record}: ${id}`)
|
||||
entries.push({
|
||||
id,
|
||||
name: record.label,
|
||||
votes: tally.get(id) ?? 0,
|
||||
voted: myVote === id,
|
||||
});
|
||||
});
|
||||
|
||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
const myVote = yVotes.get(userId) ?? null;
|
||||
|
||||
yOptions.forEach((record, id) => {
|
||||
entries.push({
|
||||
id,
|
||||
name: record.label,
|
||||
votes: tally.get(id) ?? 0,
|
||||
voted: myVote === id,
|
||||
});
|
||||
});
|
||||
|
||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -103,7 +115,6 @@ export function PollList(
|
||||
totalVotes: total,
|
||||
votingClosed,
|
||||
onVote,
|
||||
onDelete,
|
||||
});
|
||||
const currentEl = list.children[i] as HTMLElement | undefined;
|
||||
|
||||
@@ -118,9 +129,10 @@ export function PollList(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
yOptions.observeDeep(() => render());
|
||||
yVotes.observe(() => render());
|
||||
yOptions.observe(enforceAppendOnly(yOptions,(update : { [x: string]: any; }) => {currentOptions = update}, render));
|
||||
yVotes.observe(enforceAppendOnly(yVotes,(update : { [x: string]: any; }) => {currentVotes = update},render));
|
||||
currentOptions=yOptions.toJSON()
|
||||
currentVotes=yVotes.toJSON()
|
||||
render();
|
||||
|
||||
return wrapper;
|
||||
|
||||
@@ -8,11 +8,10 @@ export interface PollOptionProps {
|
||||
totalVotes: number;
|
||||
votingClosed: boolean;
|
||||
onVote: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
|
||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
||||
@@ -30,17 +29,11 @@ export function PollOption(props: PollOptionProps): HTMLElement {
|
||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
||||
${voted ? "Voted" : "Vote"}
|
||||
</button>
|
||||
<button class="poll-option__delete-btn" aria-label="Remove option">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
||||
row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,110 @@
|
||||
import type { WebrtcProvider } from "y-webrtc";
|
||||
|
||||
export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
||||
export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise<boolean>, onCreateUser: (event: Event) => Promise<boolean>): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "status-bar";
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "status-dot connecting";
|
||||
|
||||
const statusText = document.createElement("span");
|
||||
statusText.className = "status-text";
|
||||
statusText.textContent = "Connecting";
|
||||
const statusPanel=document.createElement("div");
|
||||
statusPanel.className = "status-bar";
|
||||
|
||||
const divider = document.createElement("span");
|
||||
divider.className = "status-divider";
|
||||
divider.textContent = "\u00b7";
|
||||
|
||||
const peerText = document.createElement("span");
|
||||
peerText.className = "status-peers";
|
||||
function getProviderStatus(){
|
||||
|
||||
el.append(dot, statusText, divider, peerText);
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "status-dot connecting";
|
||||
|
||||
const statusText = document.createElement("span");
|
||||
statusText.className = "status-text";
|
||||
statusText.textContent = "Connecting";
|
||||
|
||||
const peerText = document.createElement("span");
|
||||
peerText.className = "status-peers";
|
||||
|
||||
return { dot: dot, statusText: statusText, peerText: peerText}
|
||||
}
|
||||
|
||||
const providerStatusPanel=document.createElement("div");
|
||||
providerStatusPanel.className = "provider-status-container";
|
||||
|
||||
|
||||
const pollProviderText = document.createElement("span");
|
||||
pollProviderText.className = "status-text";
|
||||
pollProviderText.textContent = "Polls: ";
|
||||
const pollProviderElements = getProviderStatus()
|
||||
const pollProviderStatusPanel=document.createElement("div");
|
||||
pollProviderStatusPanel.className = "status-bar";
|
||||
pollProviderStatusPanel.append(pollProviderText,pollProviderElements.dot, pollProviderElements.statusText, divider, pollProviderElements.peerText);
|
||||
|
||||
|
||||
const userProviderText = document.createElement("span");
|
||||
userProviderText.className = "status-text";
|
||||
userProviderText.textContent = "Users: ";
|
||||
const userProviderElements = getProviderStatus()
|
||||
const userProviderStatusPanel=document.createElement("div");
|
||||
userProviderStatusPanel.className = "status-bar";
|
||||
userProviderStatusPanel.append(userProviderText,userProviderElements.dot, userProviderElements.statusText, divider, userProviderElements.peerText);
|
||||
|
||||
providerStatusPanel.append(userProviderStatusPanel,pollProviderStatusPanel)
|
||||
|
||||
const userButtons = document.createElement("div");
|
||||
userButtons.className = "status-bar";
|
||||
|
||||
const loginLabel = document.createElement("label");
|
||||
loginLabel.setAttribute("title", "Select Key File");
|
||||
const loginSpan = document.createElement("span");
|
||||
loginSpan.className = "add-option-btn"
|
||||
loginSpan.textContent = "Login";
|
||||
const loginInput = document.createElement("input");
|
||||
loginInput.type = "file"
|
||||
loginInput.accept = ".pem"
|
||||
loginInput.hidden = true
|
||||
|
||||
|
||||
loginLabel.append(loginSpan,loginInput)
|
||||
|
||||
|
||||
const logoutButton = document.createElement("button");
|
||||
logoutButton.className = "add-option-btn";
|
||||
logoutButton.setAttribute("aria-label", "Logout");
|
||||
logoutButton.innerHTML="<span>Logout</span>"
|
||||
logoutButton.style.display = "none";
|
||||
|
||||
const createUserButton = document.createElement("button");
|
||||
createUserButton.className = "add-option-btn";
|
||||
createUserButton.setAttribute("aria-label", "Create User");
|
||||
createUserButton.innerHTML="<span>Create User</span>"
|
||||
|
||||
async function onLoginLogoutResult(event: Event, loginLogout: (event: Event) => Promise<boolean>){
|
||||
if(await loginLogout(event)){
|
||||
console.log('created / logged in')
|
||||
loginLabel.style.display = "none";
|
||||
logoutButton.style.display = "block";
|
||||
createUserButton.style.display = "none";
|
||||
} else {
|
||||
console.log('logged out')
|
||||
loginLabel.style.display = "block";
|
||||
logoutButton.style.display = "none";
|
||||
createUserButton.hidden = false;
|
||||
createUserButton.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
loginLabel.addEventListener("change", (e) => onLoginLogoutResult(e,onLoginLogout));
|
||||
logoutButton.addEventListener("click", (e) => onLoginLogoutResult(e,onLoginLogout));
|
||||
createUserButton.addEventListener("click", (e) => onLoginLogoutResult(e,onCreateUser));
|
||||
|
||||
userButtons.append(loginLabel,logoutButton,createUserButton)
|
||||
|
||||
el.append(providerStatusPanel, divider, userButtons);
|
||||
|
||||
// --- Connection state ---
|
||||
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
||||
statusText.textContent = "Ready";
|
||||
dot.className = "status-dot ready";
|
||||
pollProviderElements.statusText.textContent = "Ready";
|
||||
pollProviderElements.dot.className = "status-dot ready";
|
||||
}, 3000);
|
||||
|
||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
||||
@@ -32,18 +112,37 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
||||
clearTimeout(syncTimeout);
|
||||
syncTimeout = undefined;
|
||||
}
|
||||
dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
||||
statusText.textContent = synced ? "Connected" : "Connecting";
|
||||
pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
||||
pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
|
||||
});
|
||||
|
||||
|
||||
let syncTimeout2: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
||||
userProviderElements.statusText.textContent = "Ready";
|
||||
userProviderElements.dot.className = "status-dot ready";
|
||||
}, 3000);
|
||||
|
||||
user_provider.on("synced", ({ synced }: { synced: boolean }) => {
|
||||
if (syncTimeout2) {
|
||||
clearTimeout(syncTimeout2);
|
||||
syncTimeout2 = undefined;
|
||||
}
|
||||
userProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
||||
userProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
|
||||
});
|
||||
|
||||
// Online/offline awareness
|
||||
const handleOffline = () => {
|
||||
dot.className = "status-dot connecting";
|
||||
statusText.textContent = "Offline";
|
||||
pollProviderElements.dot.className = "status-dot connecting";
|
||||
pollProviderElements.statusText.textContent = "Offline";
|
||||
userProviderElements.dot.className = "status-dot connecting";
|
||||
userProviderElements.statusText.textContent = "Offline";
|
||||
};
|
||||
const handleOnline = () => {
|
||||
dot.className = "status-dot connecting";
|
||||
statusText.textContent = "Reconnecting";
|
||||
pollProviderElements.dot.className = "status-dot connecting";
|
||||
pollProviderElements.statusText.textContent = "Reconnecting";
|
||||
userProviderElements.dot.className = "status-dot connecting";
|
||||
userProviderElements.statusText.textContent = "Reconnecting";
|
||||
};
|
||||
|
||||
window.addEventListener("offline", handleOffline);
|
||||
@@ -54,10 +153,18 @@ export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
||||
function updatePeerCount() {
|
||||
const total = provider.awareness.getStates().size;
|
||||
const others = total - 1;
|
||||
peerText.textContent =
|
||||
pollProviderElements.peerText.textContent =
|
||||
others === 0
|
||||
? "Only you"
|
||||
: `${others} other${others !== 1 ? "s" : ""}`;
|
||||
|
||||
|
||||
const total2 = user_provider.awareness.getStates().size;
|
||||
const others2 = total2 - 1;
|
||||
userProviderElements.peerText.textContent =
|
||||
others2 === 0
|
||||
? "Only you"
|
||||
: `${others2} other${others2 !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
provider.awareness.on("change", updatePeerCount);
|
||||
|
||||
119
src/crypto.ts
Normal file
119
src/crypto.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
export const generateUserKeyPair = async () => {
|
||||
return await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]), // 65537
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true, // extractable
|
||||
["sign", "verify"]
|
||||
);
|
||||
};
|
||||
|
||||
export const signData = async (data: any, privateKey: CryptoKey) => {
|
||||
const encoder = new TextEncoder();
|
||||
const encodedData = encoder.encode(JSON.stringify(data));
|
||||
|
||||
const signature = await window.crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
privateKey,
|
||||
encodedData
|
||||
);
|
||||
|
||||
// Convert to Base64 or Hex to store in Yjs easily
|
||||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||||
};
|
||||
|
||||
|
||||
// Helper to convert ArrayBuffer to Base64 string
|
||||
const bufferToBase64 = (buf: ArrayBuffer) =>
|
||||
window.btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
|
||||
export const exportPublicKey = async (key: CryptoKey) => {
|
||||
// Export Public Key
|
||||
const exportedPublic = await window.crypto.subtle.exportKey("spki", key);
|
||||
const publicKeyString = bufferToBase64(exportedPublic);
|
||||
|
||||
return publicKeyString;
|
||||
};
|
||||
export const exportPrivateKey = async (key: CryptoKey) => {
|
||||
// Export Private Key
|
||||
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", key);
|
||||
const privateKeyString = bufferToBase64(exportedPrivate);
|
||||
|
||||
return privateKeyString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Base64 string back into a usable CryptoKey object
|
||||
* @param keyStr The Base64 string (without PEM headers)
|
||||
* @param type 'public' or 'private'
|
||||
*/
|
||||
export const stringToCryptoKey = async (keyStr: string, type: 'public' | 'private'): Promise<CryptoKey> => {
|
||||
// 1. Convert Base64 string to a Uint8Array (binary)
|
||||
const binaryString = window.atob(keyStr);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
// 2. Identify the format based on the key type
|
||||
// Public keys usually use 'spki', Private keys use 'pkcs8'
|
||||
const format = type === 'public' ? 'spki' : 'pkcs8';
|
||||
const usages: KeyUsage[] = type === 'public' ? ['verify'] : ['sign'];
|
||||
|
||||
// 3. Import the key
|
||||
return await window.crypto.subtle.importKey(
|
||||
format,
|
||||
bytes.buffer,
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true, // extractable (set to false if you want to lock it in memory)
|
||||
usages
|
||||
);
|
||||
};
|
||||
|
||||
export const savePrivateKeyToFile = (privateKeyStr: string, filename: string) => {
|
||||
// Optional: Wrap in PEM headers for standard formatting
|
||||
const pemHeader = "-----BEGIN PRIVATE KEY-----\n";
|
||||
const pemFooter = "\n-----END PRIVATE KEY-----";
|
||||
const fileContent = pemHeader + privateKeyStr + pemFooter;
|
||||
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const loadPrivateKeyFromFile = async (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
|
||||
// Clean up the string by removing PEM headers and newlines
|
||||
const cleanKey = content
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replace(/\s+/g, ""); // Removes all whitespace/newlines
|
||||
|
||||
resolve(cleanKey);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject("Error reading file");
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
109
src/state.ts
109
src/state.ts
@@ -4,6 +4,12 @@ import * as Y from "yjs";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
||||
|
||||
export interface User{
|
||||
userid: string,
|
||||
private_key: CryptoKey,
|
||||
public_key: CryptoKey | undefined,
|
||||
}
|
||||
|
||||
export interface OptionRecord {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -11,25 +17,6 @@ export interface OptionRecord {
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export interface PollOptionViewModel extends OptionRecord {
|
||||
voteCount: number;
|
||||
isVotedByMe: boolean;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface PollViewModel {
|
||||
title: string;
|
||||
roomId: string;
|
||||
shareUrl: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
peerCount: number;
|
||||
options: PollOptionViewModel[];
|
||||
totalVotes: number;
|
||||
myVoteOptionId: string | null;
|
||||
deadline: number | null;
|
||||
votingClosed: boolean;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
export function createOptionId(): string {
|
||||
@@ -104,20 +91,6 @@ export function toggleVote(
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteOption(
|
||||
options: Y.Map<OptionRecord>,
|
||||
votes: Y.Map<string>,
|
||||
optionId: string,
|
||||
): void {
|
||||
options.delete(optionId);
|
||||
// Clean up votes pointing to this option
|
||||
for (const [userId, votedOptionId] of votes.entries()) {
|
||||
if (votedOptionId === optionId) {
|
||||
votes.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Deadline ---
|
||||
|
||||
export function setDeadline(
|
||||
@@ -135,73 +108,3 @@ export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
|
||||
const val = deadlineMap.get("deadline");
|
||||
return typeof val === "number" ? val : null;
|
||||
}
|
||||
|
||||
// --- ViewModel ---
|
||||
|
||||
export function createViewModel(params: {
|
||||
yTitle: Y.Text;
|
||||
options: Y.Map<OptionRecord>;
|
||||
votes: Y.Map<string>;
|
||||
deadlineMap: Y.Map<unknown>;
|
||||
roomId: string;
|
||||
shareUrl: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
peerCount: number;
|
||||
userId: string;
|
||||
}): PollViewModel {
|
||||
const {
|
||||
yTitle,
|
||||
options,
|
||||
votes,
|
||||
deadlineMap,
|
||||
roomId,
|
||||
shareUrl,
|
||||
connectionStatus,
|
||||
peerCount,
|
||||
userId,
|
||||
} = params;
|
||||
|
||||
// Tally votes per option
|
||||
const tally = new Map<string, number>();
|
||||
for (const optionId of votes.values()) {
|
||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let totalVotes = 0;
|
||||
for (const count of tally.values()) {
|
||||
totalVotes += count;
|
||||
}
|
||||
|
||||
const myVoteOptionId = votes.get(userId) ?? null;
|
||||
const deadline = getDeadline(deadlineMap);
|
||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
||||
|
||||
// Sort by votes desc, then alphabetically
|
||||
const sortedOptions = Array.from(options.values()).sort((a, b) => {
|
||||
const aVotes = tally.get(a.id) ?? 0;
|
||||
const bVotes = tally.get(b.id) ?? 0;
|
||||
if (bVotes !== aVotes) return bVotes - aVotes;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return {
|
||||
title: getPollTitle(yTitle),
|
||||
roomId,
|
||||
shareUrl,
|
||||
connectionStatus,
|
||||
peerCount,
|
||||
myVoteOptionId,
|
||||
totalVotes,
|
||||
deadline,
|
||||
votingClosed,
|
||||
options: sortedOptions.map((option) => ({
|
||||
...option,
|
||||
voteCount: tally.get(option.id) ?? 0,
|
||||
isVotedByMe: myVoteOptionId === option.id,
|
||||
percentage:
|
||||
totalVotes > 0
|
||||
? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100)
|
||||
: 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ button { cursor: pointer; }
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.provider-status-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function initSync(roomId: string): AppSync {
|
||||
: "offline";
|
||||
|
||||
const provider = new WebrtcProvider(roomId, doc,{
|
||||
signaling: ["ws://localhost:4444"]
|
||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
||||
});
|
||||
const persistence = new IndexeddbPersistence(roomId, doc);
|
||||
|
||||
|
||||
70
src/userSync.ts
Normal file
70
src/userSync.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
import { WebrtcProvider } from "y-webrtc";
|
||||
import * as Y from "yjs";
|
||||
|
||||
import type { ConnectionStatus, OptionRecord } from "./state";
|
||||
|
||||
export interface UserSync {
|
||||
doc: Y.Doc;
|
||||
users: Y.Map<string>;
|
||||
provider: WebrtcProvider;
|
||||
persistence: IndexeddbPersistence;
|
||||
getConnectionStatus: () => ConnectionStatus;
|
||||
getPeerCount: () => number;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function initUserSync(): UserSync {
|
||||
const doc = new Y.Doc();
|
||||
const users = doc.getMap<string>("users");
|
||||
|
||||
let connectionStatus: ConnectionStatus = navigator.onLine
|
||||
? "connecting"
|
||||
: "offline";
|
||||
|
||||
const provider = new WebrtcProvider("users", doc,{
|
||||
signaling: ["ws://localhost:4444", "ws://lynxpi.ddns.net:4444"]
|
||||
});
|
||||
const persistence = new IndexeddbPersistence("users", doc);
|
||||
|
||||
const syncConnectionStatus = (status: ConnectionStatus) => {
|
||||
connectionStatus = navigator.onLine ? status : "offline";
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
syncConnectionStatus(provider.connected ? "connected" : "connecting");
|
||||
};
|
||||
const handleOffline = () => {
|
||||
connectionStatus = "offline";
|
||||
};
|
||||
|
||||
provider.on("status", (event: { connected: boolean }) => {
|
||||
syncConnectionStatus(event.connected ? "connected" : "connecting");
|
||||
});
|
||||
|
||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
||||
if (synced) syncConnectionStatus("connected");
|
||||
});
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
return {
|
||||
doc,
|
||||
users,
|
||||
provider,
|
||||
persistence,
|
||||
getConnectionStatus: () => connectionStatus,
|
||||
getPeerCount: () => {
|
||||
const total = provider.awareness.getStates().size;
|
||||
return Math.max(0, total - 1);
|
||||
},
|
||||
destroy: () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
persistence.destroy();
|
||||
provider.destroy();
|
||||
doc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
24
src/yDocUtil.ts
Normal file
24
src/yDocUtil.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as Y from "yjs";
|
||||
|
||||
/**
|
||||
* Enforces append-only logic on a Y.Map.
|
||||
* Reverts any 'update' or 'delete' actions detected in the observer.
|
||||
*/
|
||||
export function enforceAppendOnly(yMap: Y.Map<any>,update: (update : { [x: string]: any; }) => void, render: () => void) {
|
||||
return (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
|
||||
var isOperationIllegal = false
|
||||
event.keys.forEach((change, key) => {
|
||||
const { action, oldValue } = change;
|
||||
|
||||
if (action === 'update' || action === 'delete') {
|
||||
isOperationIllegal = true
|
||||
console.log("Illegal Operation: "+action)
|
||||
}
|
||||
});
|
||||
if(!isOperationIllegal) {
|
||||
console.log("Updating Map!")
|
||||
update(yMap.toJSON())
|
||||
render();
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user