* revert changes
This commit is contained in:
112
src/app.ts
112
src/app.ts
@@ -1,21 +1,19 @@
|
|||||||
|
import { getUserId } from "./identity";
|
||||||
import {
|
import {
|
||||||
User,
|
|
||||||
addOption,
|
addOption,
|
||||||
toggleVote,
|
toggleVote,
|
||||||
|
deleteOption,
|
||||||
setDeadline,
|
setDeadline,
|
||||||
clearDeadline,
|
clearDeadline,
|
||||||
getDeadline,
|
createViewModel,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { initSync } from "./sync";
|
import { initSync } from "./sync";
|
||||||
import { initUserSync } from "./userSync";
|
|
||||||
import { StatusBar } from "./components/StatusBar";
|
import { StatusBar } from "./components/StatusBar";
|
||||||
import { PollTitle } from "./components/PollTitle";
|
import { PollTitle } from "./components/PollTitle";
|
||||||
import { AddOption } from "./components/AddOption";
|
import { AddOption } from "./components/AddOption";
|
||||||
import { PollList } from "./components/PollList";
|
import { PollList } from "./components/PollList";
|
||||||
import { ShareSection } from "./components/ShareSection";
|
import { ShareSection } from "./components/ShareSection";
|
||||||
import { DeadlineTimer } from "./components/DeadlineTimer";
|
import { DeadlineTimer } from "./components/DeadlineTimer";
|
||||||
import { generateUserKeyPair, exportPrivateKey, savePrivateKeyToFile, exportPublicKey, stringToCryptoKey } from "./crypto";
|
|
||||||
|
|
||||||
const ROOM_PARAM = "room";
|
const ROOM_PARAM = "room";
|
||||||
|
|
||||||
@@ -41,9 +39,8 @@ function ensureRoomId(): string {
|
|||||||
|
|
||||||
export function initApp(container: HTMLElement): () => void {
|
export function initApp(container: HTMLElement): () => void {
|
||||||
const roomId = ensureRoomId();
|
const roomId = ensureRoomId();
|
||||||
|
const userId = getUserId();
|
||||||
const sync = initSync(roomId);
|
const sync = initSync(roomId);
|
||||||
const userSync = initUserSync();
|
|
||||||
let user : User | undefined = undefined;
|
|
||||||
|
|
||||||
const shareUrl = window.location.href;
|
const shareUrl = window.location.href;
|
||||||
|
|
||||||
@@ -51,12 +48,17 @@ export function initApp(container: HTMLElement): () => void {
|
|||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
addOption: (label: string) => {
|
addOption: (label: string) => {
|
||||||
if (!user || isVotingClosed()) return;
|
const vm = createViewModel(getViewModelParams());
|
||||||
return addOption(sync.options, label, user.userid);
|
if (vm.votingClosed) return;
|
||||||
|
return addOption(sync.options, label, userId);
|
||||||
},
|
},
|
||||||
toggleVote: (optionId: string) => {
|
toggleVote: (optionId: string) => {
|
||||||
if (!user || isVotingClosed()) return;
|
const vm = createViewModel(getViewModelParams());
|
||||||
toggleVote(sync.votes, user.userid, optionId);
|
if (vm.votingClosed) return;
|
||||||
|
toggleVote(sync.votes, userId, optionId);
|
||||||
|
},
|
||||||
|
deleteOption: (optionId: string) => {
|
||||||
|
deleteOption(sync.options, sync.votes, optionId);
|
||||||
},
|
},
|
||||||
startDeadline: (durationMs: number) => {
|
startDeadline: (durationMs: number) => {
|
||||||
setDeadline(sync.deadlineMap, durationMs);
|
setDeadline(sync.deadlineMap, durationMs);
|
||||||
@@ -64,79 +66,20 @@ export function initApp(container: HTMLElement): () => void {
|
|||||||
clearDeadline: () => {
|
clearDeadline: () => {
|
||||||
clearDeadline(sync.deadlineMap);
|
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);
|
function getViewModelParams() {
|
||||||
return true;
|
return {
|
||||||
} catch (err) {
|
yTitle: sync.yTitle,
|
||||||
console.error("Crypto Import Error:", err);
|
options: sync.options,
|
||||||
alert("The file content is not a valid Private Key.");
|
votes: sync.votes,
|
||||||
}
|
deadlineMap: sync.deadlineMap,
|
||||||
}
|
roomId,
|
||||||
} catch (e) {
|
shareUrl,
|
||||||
console.error("Failed to read file", e);
|
connectionStatus: sync.getConnectionStatus(),
|
||||||
}
|
peerCount: sync.getPeerCount(),
|
||||||
}
|
userId,
|
||||||
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 isVotingClosed() {
|
|
||||||
const deadline = getDeadline(sync.deadlineMap);
|
|
||||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
|
||||||
return votingClosed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Build UI ---
|
// --- Build UI ---
|
||||||
@@ -156,7 +99,7 @@ export function initApp(container: HTMLElement): () => void {
|
|||||||
<span>Polly</span>
|
<span>Polly</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
|
const statusBar = StatusBar(sync.provider);
|
||||||
header.append(wordmark, statusBar);
|
header.append(wordmark, statusBar);
|
||||||
|
|
||||||
// Main card
|
// Main card
|
||||||
@@ -169,7 +112,10 @@ export function initApp(container: HTMLElement): () => void {
|
|||||||
if (result && !result.ok) return result.error;
|
if (result && !result.ok) return result.error;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const pollList = PollList(sync.options, sync.votes, user, isVotingClosed, actions.toggleVote);
|
const pollList = PollList(sync.options, sync.votes, userId, () => {
|
||||||
|
const vm = createViewModel(getViewModelParams());
|
||||||
|
return vm.votingClosed;
|
||||||
|
}, actions.toggleVote, actions.deleteOption);
|
||||||
const deadlineTimer = DeadlineTimer(
|
const deadlineTimer = DeadlineTimer(
|
||||||
sync.deadlineMap,
|
sync.deadlineMap,
|
||||||
actions.startDeadline,
|
actions.startDeadline,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import { getDeadline } from "../state";
|
import { getDeadline } from "../state";
|
||||||
import { enforceAppendOnly } from "../yDocUtil";
|
|
||||||
|
|
||||||
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ export function DeadlineTimer(
|
|||||||
onClearDeadline();
|
onClearDeadline();
|
||||||
});
|
});
|
||||||
|
|
||||||
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
|
deadlineMap.observe(() => render());
|
||||||
render();
|
render();
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import type { OptionRecord } from "../state";
|
import type { OptionRecord } from "../state";
|
||||||
import { PollOption } from "./PollOption";
|
import { PollOption } from "./PollOption";
|
||||||
import { enforceAppendOnly } from "../yDocUtil";
|
|
||||||
import { User } from "../state";
|
|
||||||
|
|
||||||
export function PollList(
|
export function PollList(
|
||||||
yOptions: Y.Map<OptionRecord>,
|
yOptions: Y.Map<OptionRecord>,
|
||||||
yVotes: Y.Map<string>,
|
yVotes: Y.Map<string>,
|
||||||
user: User | undefined,
|
userId: string,
|
||||||
isVotingClosed: () => boolean,
|
isVotingClosed: () => boolean,
|
||||||
onVote: (optionId: string) => void,
|
onVote: (optionId: string) => void,
|
||||||
|
onDelete: (optionId: string) => void,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
|
|
||||||
var currentOptions : { [x: string]: any; } | undefined = undefined
|
|
||||||
var currentVotes : { [x: string]: any; } | undefined = undefined
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "poll-list-wrapper";
|
wrapper.className = "poll-list-wrapper";
|
||||||
|
|
||||||
@@ -46,21 +41,16 @@ export function PollList(
|
|||||||
votes: number;
|
votes: number;
|
||||||
voted: boolean;
|
voted: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
if (currentOptions && currentVotes){
|
|
||||||
|
|
||||||
// Tally votes per option
|
// Tally votes per option
|
||||||
const tally = new Map<string, number>();
|
const tally = new Map<string, number>();
|
||||||
for (const optionId of Object.values(currentVotes)) {
|
for (const optionId of yVotes.values()) {
|
||||||
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let myVote = null;
|
const myVote = yVotes.get(userId) ?? null;
|
||||||
if (user) {
|
|
||||||
myVote = currentVotes[user.userid]
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(currentOptions).forEach(([id,record]) => {
|
yOptions.forEach((record, id) => {
|
||||||
console.log(`${record}: ${id}`)
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id,
|
id,
|
||||||
name: record.label,
|
name: record.label,
|
||||||
@@ -70,8 +60,6 @@ export function PollList(
|
|||||||
});
|
});
|
||||||
|
|
||||||
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
entries.sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name));
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +103,7 @@ export function PollList(
|
|||||||
totalVotes: total,
|
totalVotes: total,
|
||||||
votingClosed,
|
votingClosed,
|
||||||
onVote,
|
onVote,
|
||||||
|
onDelete,
|
||||||
});
|
});
|
||||||
const currentEl = list.children[i] as HTMLElement | undefined;
|
const currentEl = list.children[i] as HTMLElement | undefined;
|
||||||
|
|
||||||
@@ -129,10 +118,9 @@ export function PollList(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
yOptions.observe(enforceAppendOnly(yOptions,(update : { [x: string]: any; }) => {currentOptions = update}, render));
|
|
||||||
yVotes.observe(enforceAppendOnly(yVotes,(update : { [x: string]: any; }) => {currentVotes = update},render));
|
yOptions.observeDeep(() => render());
|
||||||
currentOptions=yOptions.toJSON()
|
yVotes.observe(() => render());
|
||||||
currentVotes=yVotes.toJSON()
|
|
||||||
render();
|
render();
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ export interface PollOptionProps {
|
|||||||
totalVotes: number;
|
totalVotes: number;
|
||||||
votingClosed: boolean;
|
votingClosed: boolean;
|
||||||
onVote: (id: string) => void;
|
onVote: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
export function PollOption(props: PollOptionProps): HTMLElement {
|
||||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props;
|
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
||||||
@@ -29,11 +30,17 @@ export function PollOption(props: PollOptionProps): HTMLElement {
|
|||||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
||||||
${voted ? "Voted" : "Vote"}
|
${voted ? "Voted" : "Vote"}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
||||||
|
row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id));
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import type { WebrtcProvider } from "y-webrtc";
|
import type { WebrtcProvider } from "y-webrtc";
|
||||||
|
|
||||||
export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvider, onLoginLogout: (event: Event) => Promise<boolean>, onCreateUser: (event: Event) => Promise<boolean>): HTMLElement {
|
export function StatusBar(provider: WebrtcProvider): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "status-bar";
|
el.className = "status-bar";
|
||||||
|
|
||||||
const statusPanel=document.createElement("div");
|
|
||||||
statusPanel.className = "status-bar";
|
|
||||||
|
|
||||||
const divider = document.createElement("span");
|
|
||||||
divider.className = "status-divider";
|
|
||||||
divider.textContent = "\u00b7";
|
|
||||||
|
|
||||||
function getProviderStatus(){
|
|
||||||
|
|
||||||
const dot = document.createElement("span");
|
const dot = document.createElement("span");
|
||||||
dot.className = "status-dot connecting";
|
dot.className = "status-dot connecting";
|
||||||
|
|
||||||
@@ -20,91 +11,20 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide
|
|||||||
statusText.className = "status-text";
|
statusText.className = "status-text";
|
||||||
statusText.textContent = "Connecting";
|
statusText.textContent = "Connecting";
|
||||||
|
|
||||||
|
const divider = document.createElement("span");
|
||||||
|
divider.className = "status-divider";
|
||||||
|
divider.textContent = "\u00b7";
|
||||||
|
|
||||||
const peerText = document.createElement("span");
|
const peerText = document.createElement("span");
|
||||||
peerText.className = "status-peers";
|
peerText.className = "status-peers";
|
||||||
|
|
||||||
return { dot: dot, statusText: statusText, peerText: peerText}
|
el.append(dot, statusText, divider, 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 ---
|
// --- Connection state ---
|
||||||
|
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
|
||||||
pollProviderElements.statusText.textContent = "Ready";
|
statusText.textContent = "Ready";
|
||||||
pollProviderElements.dot.className = "status-dot ready";
|
dot.className = "status-dot ready";
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
provider.on("synced", ({ synced }: { synced: boolean }) => {
|
||||||
@@ -112,37 +32,18 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide
|
|||||||
clearTimeout(syncTimeout);
|
clearTimeout(syncTimeout);
|
||||||
syncTimeout = undefined;
|
syncTimeout = undefined;
|
||||||
}
|
}
|
||||||
pollProviderElements.dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
|
||||||
pollProviderElements.statusText.textContent = synced ? "Connected" : "Connecting";
|
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
|
// Online/offline awareness
|
||||||
const handleOffline = () => {
|
const handleOffline = () => {
|
||||||
pollProviderElements.dot.className = "status-dot connecting";
|
dot.className = "status-dot connecting";
|
||||||
pollProviderElements.statusText.textContent = "Offline";
|
statusText.textContent = "Offline";
|
||||||
userProviderElements.dot.className = "status-dot connecting";
|
|
||||||
userProviderElements.statusText.textContent = "Offline";
|
|
||||||
};
|
};
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
pollProviderElements.dot.className = "status-dot connecting";
|
dot.className = "status-dot connecting";
|
||||||
pollProviderElements.statusText.textContent = "Reconnecting";
|
statusText.textContent = "Reconnecting";
|
||||||
userProviderElements.dot.className = "status-dot connecting";
|
|
||||||
userProviderElements.statusText.textContent = "Reconnecting";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("offline", handleOffline);
|
window.addEventListener("offline", handleOffline);
|
||||||
@@ -153,18 +54,10 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide
|
|||||||
function updatePeerCount() {
|
function updatePeerCount() {
|
||||||
const total = provider.awareness.getStates().size;
|
const total = provider.awareness.getStates().size;
|
||||||
const others = total - 1;
|
const others = total - 1;
|
||||||
pollProviderElements.peerText.textContent =
|
peerText.textContent =
|
||||||
others === 0
|
others === 0
|
||||||
? "Only you"
|
? "Only you"
|
||||||
: `${others} other${others !== 1 ? "s" : ""}`;
|
: `${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);
|
provider.awareness.on("change", updatePeerCount);
|
||||||
|
|||||||
119
src/crypto.ts
119
src/crypto.ts
@@ -1,119 +0,0 @@
|
|||||||
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,12 +4,6 @@ import * as Y from "yjs";
|
|||||||
|
|
||||||
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
export type ConnectionStatus = "connecting" | "connected" | "offline";
|
||||||
|
|
||||||
export interface User{
|
|
||||||
userid: string,
|
|
||||||
private_key: CryptoKey,
|
|
||||||
public_key: CryptoKey | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OptionRecord {
|
export interface OptionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,6 +11,25 @@ export interface OptionRecord {
|
|||||||
createdBy: string;
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
export function createOptionId(): string {
|
export function createOptionId(): string {
|
||||||
@@ -91,6 +104,20 @@ 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 ---
|
// --- Deadline ---
|
||||||
|
|
||||||
export function setDeadline(
|
export function setDeadline(
|
||||||
@@ -108,3 +135,73 @@ export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
|
|||||||
const val = deadlineMap.get("deadline");
|
const val = deadlineMap.get("deadline");
|
||||||
return typeof val === "number" ? val : null;
|
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,14 +86,6 @@ button { cursor: pointer; }
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
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 {
|
.status-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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