Merge final group into main #3

Merged
quic merged 10 commits from group-26d3b827-6587-46c2-a46e-001281299174 into main 2026-05-10 20:10:02 +00:00
10 changed files with 183 additions and 474 deletions
Showing only changes of commit 1ef970fef3 - Show all commits

View File

@@ -1,21 +1,19 @@
import { getUserId } from "./identity";
import {
User,
addOption,
toggleVote,
deleteOption,
setDeadline,
clearDeadline,
getDeadline,
createViewModel,
} 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";
@@ -41,9 +39,8 @@ 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;
@@ -51,12 +48,17 @@ export function initApp(container: HTMLElement): () => void {
const actions = {
addOption: (label: string) => {
if (!user || isVotingClosed()) return;
return addOption(sync.options, label, user.userid);
const vm = createViewModel(getViewModelParams());
if (vm.votingClosed) return;
return addOption(sync.options, label, userId);
},
toggleVote: (optionId: string) => {
if (!user || isVotingClosed()) return;
toggleVote(sync.votes, user.userid, optionId);
const vm = createViewModel(getViewModelParams());
if (vm.votingClosed) return;
toggleVote(sync.votes, userId, optionId);
},
deleteOption: (optionId: string) => {
deleteOption(sync.options, sync.votes, optionId);
},
startDeadline: (durationMs: number) => {
setDeadline(sync.deadlineMap, durationMs);
@@ -64,79 +66,20 @@ 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 isVotingClosed() {
const deadline = getDeadline(sync.deadlineMap);
const votingClosed = deadline !== null && Date.now() >= deadline;
return votingClosed;
function getViewModelParams() {
return {
yTitle: sync.yTitle,
options: sync.options,
votes: sync.votes,
deadlineMap: sync.deadlineMap,
roomId,
shareUrl,
connectionStatus: sync.getConnectionStatus(),
peerCount: sync.getPeerCount(),
userId,
};
}
// --- Build UI ---
@@ -156,7 +99,7 @@ export function initApp(container: HTMLElement): () => void {
<span>Polly</span>
`;
const statusBar = StatusBar(sync.provider,userSync.provider,actions.onLoginLogout,actions.onCreateUser);
const statusBar = StatusBar(sync.provider);
header.append(wordmark, statusBar);
// Main card
@@ -169,7 +112,10 @@ export function initApp(container: HTMLElement): () => void {
if (result && !result.ok) return result.error;
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(
sync.deadlineMap,
actions.startDeadline,

View File

@@ -1,6 +1,5 @@
import * as Y from "yjs";
import { getDeadline } from "../state";
import { enforceAppendOnly } from "../yDocUtil";
const DEADLINE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
@@ -80,7 +79,7 @@ export function DeadlineTimer(
onClearDeadline();
});
deadlineMap.observe(enforceAppendOnly(deadlineMap,render));
deadlineMap.observe(() => render());
render();
return wrapper;

View File

@@ -1,20 +1,15 @@
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>,
user: User | undefined,
userId: string,
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";
@@ -46,32 +41,25 @@ export function PollList(
votes: number;
voted: boolean;
}> = [];
if (currentOptions && currentVotes){
// 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));
// Tally votes per option
const tally = new Map<string, number>();
for (const optionId of yVotes.values()) {
tally.set(optionId, (tally.get(optionId) ?? 0) + 1);
}
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;
}
@@ -115,6 +103,7 @@ export function PollList(
totalVotes: total,
votingClosed,
onVote,
onDelete,
});
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));
currentOptions=yOptions.toJSON()
currentVotes=yVotes.toJSON()
yOptions.observeDeep(() => render());
yVotes.observe(() => render());
render();
return wrapper;

View File

@@ -8,10 +8,11 @@ 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 } = props;
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
const row = document.createElement("div");
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" : ""}>
${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;
}

View File

@@ -1,110 +1,30 @@
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");
el.className = "status-bar";
const statusPanel=document.createElement("div");
statusPanel.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 divider = document.createElement("span");
divider.className = "status-divider";
divider.textContent = "\u00b7";
function getProviderStatus(){
const peerText = document.createElement("span");
peerText.className = "status-peers";
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);
el.append(dot, statusText, divider, peerText);
// --- Connection state ---
let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
pollProviderElements.statusText.textContent = "Ready";
pollProviderElements.dot.className = "status-dot ready";
statusText.textContent = "Ready";
dot.className = "status-dot ready";
}, 3000);
provider.on("synced", ({ synced }: { synced: boolean }) => {
@@ -112,37 +32,18 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide
clearTimeout(syncTimeout);
syncTimeout = undefined;
}
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";
dot.className = `status-dot ${synced ? "connected" : "connecting"}`;
statusText.textContent = synced ? "Connected" : "Connecting";
});
// Online/offline awareness
const handleOffline = () => {
pollProviderElements.dot.className = "status-dot connecting";
pollProviderElements.statusText.textContent = "Offline";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Offline";
dot.className = "status-dot connecting";
statusText.textContent = "Offline";
};
const handleOnline = () => {
pollProviderElements.dot.className = "status-dot connecting";
pollProviderElements.statusText.textContent = "Reconnecting";
userProviderElements.dot.className = "status-dot connecting";
userProviderElements.statusText.textContent = "Reconnecting";
dot.className = "status-dot connecting";
statusText.textContent = "Reconnecting";
};
window.addEventListener("offline", handleOffline);
@@ -153,18 +54,10 @@ export function StatusBar(provider: WebrtcProvider, user_provider: WebrtcProvide
function updatePeerCount() {
const total = provider.awareness.getStates().size;
const others = total - 1;
pollProviderElements.peerText.textContent =
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);

View File

@@ -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);
});
};

View File

@@ -4,12 +4,6 @@ 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;
@@ -17,6 +11,25 @@ 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 {
@@ -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 ---
export function setDeadline(
@@ -108,3 +135,73 @@ 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,
})),
};
}

View File

@@ -86,14 +86,6 @@ 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;

View File

@@ -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();
},
};
}

View File

@@ -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();
}
};
}