@mideind/netskrafl-react 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/css/netskrafl.css +2 -2
- package/dist/cjs/index.js +809 -441
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/css/netskrafl.css +2 -2
- package/dist/esm/index.js +809 -441
- package/dist/esm/index.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,8 +1,65 @@
|
|
|
1
1
|
import { jsx } from 'react/jsx-runtime';
|
|
2
2
|
import React, { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
+
const DEFAULT_STATE = {
|
|
5
|
+
projectId: "netskrafl",
|
|
6
|
+
firebaseApiKey: "",
|
|
7
|
+
databaseUrl: "",
|
|
8
|
+
firebaseSenderId: "",
|
|
9
|
+
firebaseAppId: "",
|
|
10
|
+
measurementId: "",
|
|
11
|
+
account: "",
|
|
12
|
+
userEmail: "",
|
|
13
|
+
userId: "",
|
|
14
|
+
userNick: "",
|
|
15
|
+
userFullname: "",
|
|
16
|
+
locale: "is_IS",
|
|
17
|
+
isExplo: false,
|
|
18
|
+
serverUrl: "",
|
|
19
|
+
movesUrl: "",
|
|
20
|
+
movesAccessKey: "",
|
|
21
|
+
token: "",
|
|
22
|
+
loginMethod: "",
|
|
23
|
+
subscriptionUrl: "",
|
|
24
|
+
newUser: false,
|
|
25
|
+
beginner: true,
|
|
26
|
+
fairPlay: false,
|
|
27
|
+
plan: "", // Not a friend
|
|
28
|
+
hasPaid: false,
|
|
29
|
+
audio: false,
|
|
30
|
+
fanfare: false,
|
|
31
|
+
ready: true,
|
|
32
|
+
readyTimed: true,
|
|
33
|
+
uiFullscreen: true,
|
|
34
|
+
uiLandscape: false,
|
|
35
|
+
runningLocal: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
4
38
|
// Key for storing auth settings in sessionStorage
|
|
5
39
|
const AUTH_SETTINGS_KEY = "netskrafl_auth_settings";
|
|
40
|
+
const makeServerUrls = (backendUrl, movesUrl) => {
|
|
41
|
+
// If the last character of the url is a slash, cut it off,
|
|
42
|
+
// since path URLs always start with a slash
|
|
43
|
+
const cleanupUrl = (url) => {
|
|
44
|
+
if (url.length > 0 && url[url.length - 1] === "/") {
|
|
45
|
+
url = url.slice(0, -1);
|
|
46
|
+
}
|
|
47
|
+
return url;
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
serverUrl: cleanupUrl(backendUrl),
|
|
51
|
+
movesUrl: cleanupUrl(movesUrl),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const makeGlobalState = (overrides) => {
|
|
55
|
+
const state = {
|
|
56
|
+
...DEFAULT_STATE,
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
|
|
60
|
+
// Apply any persisted authentication settings from sessionStorage
|
|
61
|
+
return applyPersistedSettings(stateWithUrls);
|
|
62
|
+
};
|
|
6
63
|
// Save authentication settings to sessionStorage
|
|
7
64
|
const saveAuthSettings = (settings) => {
|
|
8
65
|
if (!settings) {
|
|
@@ -31,6 +88,10 @@ const saveAuthSettings = (settings) => {
|
|
|
31
88
|
filteredSettings.ready = settings.ready;
|
|
32
89
|
if (settings.readyTimed !== undefined)
|
|
33
90
|
filteredSettings.readyTimed = settings.readyTimed;
|
|
91
|
+
if (settings.audio !== undefined)
|
|
92
|
+
filteredSettings.audio = settings.audio;
|
|
93
|
+
if (settings.fanfare !== undefined)
|
|
94
|
+
filteredSettings.fanfare = settings.fanfare;
|
|
34
95
|
// Only save if we have actual settings to persist
|
|
35
96
|
if (Object.keys(filteredSettings).length > 1) {
|
|
36
97
|
sessionStorage.setItem(AUTH_SETTINGS_KEY, JSON.stringify(filteredSettings));
|
|
@@ -69,7 +130,7 @@ const clearAuthSettings = () => {
|
|
|
69
130
|
};
|
|
70
131
|
// Apply persisted settings to a GlobalState object
|
|
71
132
|
const applyPersistedSettings = (state) => {
|
|
72
|
-
var _a, _b, _c, _d;
|
|
133
|
+
var _a, _b, _c, _d, _e, _f;
|
|
73
134
|
const persisted = loadAuthSettings();
|
|
74
135
|
if (!persisted) {
|
|
75
136
|
return state;
|
|
@@ -95,64 +156,11 @@ const applyPersistedSettings = (state) => {
|
|
|
95
156
|
fairPlay: (_b = persisted.fairPlay) !== null && _b !== void 0 ? _b : state.fairPlay,
|
|
96
157
|
ready: (_c = persisted.ready) !== null && _c !== void 0 ? _c : state.ready,
|
|
97
158
|
readyTimed: (_d = persisted.readyTimed) !== null && _d !== void 0 ? _d : state.readyTimed,
|
|
159
|
+
audio: (_e = persisted.audio) !== null && _e !== void 0 ? _e : state.audio,
|
|
160
|
+
fanfare: (_f = persisted.fanfare) !== null && _f !== void 0 ? _f : state.fanfare,
|
|
98
161
|
};
|
|
99
162
|
};
|
|
100
163
|
|
|
101
|
-
const DEFAULT_STATE = {
|
|
102
|
-
projectId: "netskrafl",
|
|
103
|
-
firebaseApiKey: "",
|
|
104
|
-
databaseUrl: "",
|
|
105
|
-
firebaseSenderId: "",
|
|
106
|
-
firebaseAppId: "",
|
|
107
|
-
measurementId: "",
|
|
108
|
-
account: "",
|
|
109
|
-
userEmail: "",
|
|
110
|
-
userId: "",
|
|
111
|
-
userNick: "",
|
|
112
|
-
userFullname: "",
|
|
113
|
-
locale: "is_IS",
|
|
114
|
-
isExplo: false,
|
|
115
|
-
serverUrl: "",
|
|
116
|
-
movesUrl: "",
|
|
117
|
-
movesAccessKey: "",
|
|
118
|
-
token: "",
|
|
119
|
-
loginMethod: "",
|
|
120
|
-
subscriptionUrl: "",
|
|
121
|
-
newUser: false,
|
|
122
|
-
beginner: true,
|
|
123
|
-
fairPlay: false,
|
|
124
|
-
plan: "", // Not a friend
|
|
125
|
-
hasPaid: false,
|
|
126
|
-
ready: true,
|
|
127
|
-
readyTimed: true,
|
|
128
|
-
uiFullscreen: true,
|
|
129
|
-
uiLandscape: false,
|
|
130
|
-
runningLocal: false,
|
|
131
|
-
};
|
|
132
|
-
const makeServerUrls = (backendUrl, movesUrl) => {
|
|
133
|
-
// If the last character of the url is a slash, cut it off,
|
|
134
|
-
// since path URLs always start with a slash
|
|
135
|
-
const cleanupUrl = (url) => {
|
|
136
|
-
if (url.length > 0 && url[url.length - 1] === "/") {
|
|
137
|
-
url = url.slice(0, -1);
|
|
138
|
-
}
|
|
139
|
-
return url;
|
|
140
|
-
};
|
|
141
|
-
return {
|
|
142
|
-
serverUrl: cleanupUrl(backendUrl),
|
|
143
|
-
movesUrl: cleanupUrl(movesUrl),
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
const makeGlobalState = (overrides) => {
|
|
147
|
-
const state = {
|
|
148
|
-
...DEFAULT_STATE,
|
|
149
|
-
...overrides,
|
|
150
|
-
};
|
|
151
|
-
const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
|
|
152
|
-
// Apply any persisted authentication settings from sessionStorage
|
|
153
|
-
return applyPersistedSettings(stateWithUrls);
|
|
154
|
-
};
|
|
155
|
-
|
|
156
164
|
function getDefaultExportFromCjs (x) {
|
|
157
165
|
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
158
166
|
}
|
|
@@ -2533,6 +2541,133 @@ const ERROR_MESSAGES = {
|
|
|
2533
2541
|
"server": "Netþjónn gat ekki tekið við leiknum - reyndu aftur"
|
|
2534
2542
|
};
|
|
2535
2543
|
|
|
2544
|
+
/*
|
|
2545
|
+
|
|
2546
|
+
Audio.ts
|
|
2547
|
+
|
|
2548
|
+
Audio management service for Netskrafl/Explo
|
|
2549
|
+
|
|
2550
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
2551
|
+
Author: Vilhjálmur Þorsteinsson
|
|
2552
|
+
|
|
2553
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
2554
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
2555
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
2556
|
+
|
|
2557
|
+
*/
|
|
2558
|
+
/**
|
|
2559
|
+
* AudioManager handles preloading and playback of sound effects.
|
|
2560
|
+
* It creates HTMLAudioElement instances for each sound and manages their lifecycle.
|
|
2561
|
+
*/
|
|
2562
|
+
class AudioManager {
|
|
2563
|
+
constructor(state, soundUrls) {
|
|
2564
|
+
this.sounds = new Map();
|
|
2565
|
+
this.initialized = false;
|
|
2566
|
+
// By default, sound URLs are based on /static on the backend server
|
|
2567
|
+
const DEFAULT_SOUND_BASE = serverUrl(state, "/static");
|
|
2568
|
+
const DEFAULT_SOUND_URLS = {
|
|
2569
|
+
"your-turn": `${DEFAULT_SOUND_BASE}/your-turn.mp3`,
|
|
2570
|
+
"you-win": `${DEFAULT_SOUND_BASE}/you-win.mp3`,
|
|
2571
|
+
"new-msg": `${DEFAULT_SOUND_BASE}/new-msg.mp3`,
|
|
2572
|
+
};
|
|
2573
|
+
// Merge provided URLs with defaults
|
|
2574
|
+
this.soundUrls = {
|
|
2575
|
+
...DEFAULT_SOUND_URLS,
|
|
2576
|
+
...(soundUrls || {}),
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* Initialize the audio manager by creating and preloading audio elements.
|
|
2581
|
+
* This should be called once when the application starts.
|
|
2582
|
+
*/
|
|
2583
|
+
initialize() {
|
|
2584
|
+
if (this.initialized) {
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
// Create audio elements for each sound
|
|
2588
|
+
Object.entries(this.soundUrls).forEach(([soundId, url]) => {
|
|
2589
|
+
const audio = new Audio(url);
|
|
2590
|
+
audio.preload = "auto";
|
|
2591
|
+
// Handle load errors gracefully - don't let them crash the app
|
|
2592
|
+
audio.addEventListener("error", () => {
|
|
2593
|
+
console.warn(`Failed to load audio: ${soundId} from ${url}`);
|
|
2594
|
+
});
|
|
2595
|
+
this.sounds.set(soundId, audio);
|
|
2596
|
+
});
|
|
2597
|
+
this.initialized = true;
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Play a sound by its ID.
|
|
2601
|
+
* If the sound is not loaded or fails to play, the error is logged but doesn't throw.
|
|
2602
|
+
*
|
|
2603
|
+
* @param soundId The identifier of the sound to play
|
|
2604
|
+
*/
|
|
2605
|
+
play(soundId) {
|
|
2606
|
+
if (!this.initialized) {
|
|
2607
|
+
this.initialize();
|
|
2608
|
+
}
|
|
2609
|
+
const audio = this.sounds.get(soundId);
|
|
2610
|
+
if (!audio) {
|
|
2611
|
+
console.warn(`Audio not found: ${soundId}`);
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
// Reset to start in case it's already playing
|
|
2615
|
+
audio.currentTime = 0;
|
|
2616
|
+
// Play the audio - catch any errors (e.g., user hasn't interacted with page yet)
|
|
2617
|
+
audio.play().catch((err) => {
|
|
2618
|
+
// This is expected in some cases (e.g., autoplay restrictions)
|
|
2619
|
+
// so we just log it at debug level
|
|
2620
|
+
if (err.name !== "NotAllowedError") {
|
|
2621
|
+
console.warn(`Failed to play audio ${soundId}:`, err);
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Update the URL for a specific sound.
|
|
2627
|
+
* This will recreate the audio element with the new URL.
|
|
2628
|
+
*
|
|
2629
|
+
* @param soundId The identifier of the sound to update
|
|
2630
|
+
* @param url The new URL for the sound
|
|
2631
|
+
*/
|
|
2632
|
+
updateSoundUrl(soundId, url) {
|
|
2633
|
+
this.soundUrls[soundId] = url;
|
|
2634
|
+
// If already initialized, recreate this audio element
|
|
2635
|
+
if (this.initialized) {
|
|
2636
|
+
const audio = new Audio(url);
|
|
2637
|
+
audio.preload = "auto";
|
|
2638
|
+
audio.addEventListener("error", () => {
|
|
2639
|
+
console.warn(`Failed to load audio: ${soundId} from ${url}`);
|
|
2640
|
+
});
|
|
2641
|
+
this.sounds.set(soundId, audio);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Dispose of all audio elements and clean up resources.
|
|
2646
|
+
*/
|
|
2647
|
+
dispose() {
|
|
2648
|
+
this.sounds.forEach((audio) => {
|
|
2649
|
+
audio.pause();
|
|
2650
|
+
audio.src = "";
|
|
2651
|
+
});
|
|
2652
|
+
this.sounds.clear();
|
|
2653
|
+
this.initialized = false;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
// Global singleton instance
|
|
2657
|
+
let audioManager = null;
|
|
2658
|
+
/**
|
|
2659
|
+
* Get or create the global AudioManager instance.
|
|
2660
|
+
*
|
|
2661
|
+
* @param soundUrls Optional custom sound URLs (only used on first call)
|
|
2662
|
+
* @returns The global AudioManager instance
|
|
2663
|
+
*/
|
|
2664
|
+
function getAudioManager(state, soundUrls) {
|
|
2665
|
+
if (!audioManager) {
|
|
2666
|
+
audioManager = new AudioManager(state, soundUrls);
|
|
2667
|
+
}
|
|
2668
|
+
return audioManager;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2536
2671
|
/*
|
|
2537
2672
|
|
|
2538
2673
|
Util.ts
|
|
@@ -2689,11 +2824,10 @@ function setInput(id, val) {
|
|
|
2689
2824
|
const elem = document.getElementById(id);
|
|
2690
2825
|
elem.value = val;
|
|
2691
2826
|
}
|
|
2692
|
-
function playAudio(
|
|
2693
|
-
// Play an audio file
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2696
|
-
sound.play();
|
|
2827
|
+
function playAudio(state, soundId) {
|
|
2828
|
+
// Play an audio file using the AudioManager
|
|
2829
|
+
const audioManager = getAudioManager(state);
|
|
2830
|
+
audioManager.play(soundId);
|
|
2697
2831
|
}
|
|
2698
2832
|
function arrayEqual(a, b) {
|
|
2699
2833
|
// Return true if arrays a and b are equal
|
|
@@ -27230,6 +27364,19 @@ let app;
|
|
|
27230
27364
|
let auth;
|
|
27231
27365
|
let database;
|
|
27232
27366
|
let analytics;
|
|
27367
|
+
// Queue of callbacks to execute when database is ready
|
|
27368
|
+
let databaseReadyQueue = [];
|
|
27369
|
+
/**
|
|
27370
|
+
* Execute all queued callbacks now that database is ready
|
|
27371
|
+
*/
|
|
27372
|
+
function flushDatabaseReadyQueue(state) {
|
|
27373
|
+
var _a;
|
|
27374
|
+
const queue = [...databaseReadyQueue];
|
|
27375
|
+
databaseReadyQueue = []; // Clear queue before executing to avoid re-queuing
|
|
27376
|
+
queue.forEach(callback => callback());
|
|
27377
|
+
// Also notify the global callback if set
|
|
27378
|
+
(_a = state.onFirebaseReady) === null || _a === void 0 ? void 0 : _a.call(state);
|
|
27379
|
+
}
|
|
27233
27380
|
function initFirebase(state) {
|
|
27234
27381
|
try {
|
|
27235
27382
|
const { projectId, firebaseApiKey, databaseUrl, firebaseSenderId, firebaseAppId, measurementId } = state;
|
|
@@ -27264,9 +27411,30 @@ function isFirebaseAuthenticated(state) {
|
|
|
27264
27411
|
if (app)
|
|
27265
27412
|
auth = getAuth(app);
|
|
27266
27413
|
}
|
|
27267
|
-
if (!auth)
|
|
27414
|
+
if (!auth || auth.currentUser === null) {
|
|
27268
27415
|
return false;
|
|
27269
|
-
|
|
27416
|
+
}
|
|
27417
|
+
// If auth is valid but database not initialized, initialize it
|
|
27418
|
+
// This handles the case where the user has cached auth but returns
|
|
27419
|
+
// directly to a route that needs Firebase Database (e.g., Gáta Dagsins)
|
|
27420
|
+
if (!database && app) {
|
|
27421
|
+
database = getDatabase(app);
|
|
27422
|
+
if (!database) {
|
|
27423
|
+
console.error("Failed to initialize Firebase Database");
|
|
27424
|
+
return false;
|
|
27425
|
+
}
|
|
27426
|
+
// Database initialized successfully - flush queued callbacks
|
|
27427
|
+
flushDatabaseReadyQueue(state);
|
|
27428
|
+
// Also initialize analytics if not already done
|
|
27429
|
+
if (!analytics) {
|
|
27430
|
+
analytics = getAnalytics(app);
|
|
27431
|
+
if (!analytics) {
|
|
27432
|
+
console.error("Failed to initialize Firebase Analytics");
|
|
27433
|
+
// We don't return false here since analytics is not critical
|
|
27434
|
+
}
|
|
27435
|
+
}
|
|
27436
|
+
}
|
|
27437
|
+
return true;
|
|
27270
27438
|
}
|
|
27271
27439
|
async function loginFirebase(state, firebaseToken, onLoginFunc) {
|
|
27272
27440
|
if (!app && !initFirebase(state))
|
|
@@ -27304,6 +27472,10 @@ async function loginFirebase(state, firebaseToken, onLoginFunc) {
|
|
|
27304
27472
|
if (!database) {
|
|
27305
27473
|
console.error("Failed to initialize Firebase Database");
|
|
27306
27474
|
}
|
|
27475
|
+
else {
|
|
27476
|
+
// Database initialized successfully - flush queued callbacks
|
|
27477
|
+
flushDatabaseReadyQueue(state);
|
|
27478
|
+
}
|
|
27307
27479
|
analytics = getAnalytics(app);
|
|
27308
27480
|
if (!analytics) {
|
|
27309
27481
|
console.error("Failed to initialize Firebase Analytics");
|
|
@@ -27335,8 +27507,12 @@ function initPresence(projectId, userId, locale) {
|
|
|
27335
27507
|
}
|
|
27336
27508
|
function attachFirebaseListener(path, func) {
|
|
27337
27509
|
// Attach a message listener to a Firebase path
|
|
27338
|
-
|
|
27510
|
+
// console.log(`attachFirebaseListener(${path})`);
|
|
27511
|
+
if (!database) {
|
|
27512
|
+
// Database not ready yet - queue this attachment for later
|
|
27513
|
+
databaseReadyQueue.push(() => attachFirebaseListener(path, func));
|
|
27339
27514
|
return;
|
|
27515
|
+
}
|
|
27340
27516
|
let cnt = 0;
|
|
27341
27517
|
const pathRef = ref(database, path);
|
|
27342
27518
|
onValue(pathRef, function (snapshot) {
|
|
@@ -27353,8 +27529,10 @@ function attachFirebaseListener(path, func) {
|
|
|
27353
27529
|
}
|
|
27354
27530
|
function detachFirebaseListener(path) {
|
|
27355
27531
|
// Detach a message listener from a Firebase path
|
|
27356
|
-
|
|
27532
|
+
// console.log(`detachFirebaseListener(${path})`);
|
|
27533
|
+
if (!database) {
|
|
27357
27534
|
return;
|
|
27535
|
+
}
|
|
27358
27536
|
const pathRef = ref(database, path);
|
|
27359
27537
|
off(pathRef);
|
|
27360
27538
|
}
|
|
@@ -27373,63 +27551,178 @@ async function getFirebaseData(path) {
|
|
|
27373
27551
|
return snapshot.val();
|
|
27374
27552
|
}
|
|
27375
27553
|
|
|
27376
|
-
//
|
|
27554
|
+
// ============================================================================
|
|
27555
|
+
// Configuration constants for login protection
|
|
27556
|
+
// ============================================================================
|
|
27557
|
+
// Maximum number of login attempts before giving up
|
|
27558
|
+
const MAX_LOGIN_RETRIES = 3;
|
|
27559
|
+
// Minimum time between login attempts (milliseconds) - prevents rapid-fire retries
|
|
27560
|
+
const MIN_LOGIN_INTERVAL_MS = 500;
|
|
27561
|
+
// Initial backoff delay for retries (milliseconds) - doubles each retry
|
|
27562
|
+
const INITIAL_BACKOFF_MS = 500;
|
|
27563
|
+
// Maximum backoff delay (milliseconds) - caps exponential growth
|
|
27564
|
+
const MAX_BACKOFF_MS = 5000;
|
|
27565
|
+
// Circuit breaker timeout (milliseconds) - how long to wait after max retries
|
|
27566
|
+
const CIRCUIT_BREAKER_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
|
|
27567
|
+
// ============================================================================
|
|
27568
|
+
// Global state for authentication and retry protection
|
|
27569
|
+
// ============================================================================
|
|
27570
|
+
// Active login promise - prevents concurrent login attempts
|
|
27377
27571
|
let authPromise = null;
|
|
27378
|
-
//
|
|
27572
|
+
// Retry tracking
|
|
27573
|
+
let loginAttemptCount = 0;
|
|
27574
|
+
let lastLoginAttemptTime = 0;
|
|
27575
|
+
// Circuit breaker state
|
|
27576
|
+
let circuitBreakerOpen = false;
|
|
27577
|
+
let circuitBreakerResetTime = 0;
|
|
27578
|
+
// ============================================================================
|
|
27579
|
+
// Custom error classes
|
|
27580
|
+
// ============================================================================
|
|
27379
27581
|
class AuthenticationError extends Error {
|
|
27380
27582
|
constructor() {
|
|
27381
27583
|
super("Authentication required");
|
|
27382
27584
|
this.name = "AuthenticationError";
|
|
27383
27585
|
}
|
|
27384
27586
|
}
|
|
27385
|
-
|
|
27587
|
+
class LoginThrottledError extends Error {
|
|
27588
|
+
constructor(message) {
|
|
27589
|
+
super(message);
|
|
27590
|
+
this.name = "LoginThrottledError";
|
|
27591
|
+
}
|
|
27592
|
+
}
|
|
27593
|
+
// ============================================================================
|
|
27594
|
+
// Helper functions
|
|
27595
|
+
// ============================================================================
|
|
27596
|
+
/**
|
|
27597
|
+
* Delays execution for the specified number of milliseconds
|
|
27598
|
+
*/
|
|
27599
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
27600
|
+
/**
|
|
27601
|
+
* Calculates exponential backoff delay based on attempt number
|
|
27602
|
+
*/
|
|
27603
|
+
const calculateBackoff = (attemptNumber) => {
|
|
27604
|
+
const backoff = INITIAL_BACKOFF_MS * Math.pow(2, attemptNumber - 1);
|
|
27605
|
+
return Math.min(backoff, MAX_BACKOFF_MS);
|
|
27606
|
+
};
|
|
27607
|
+
/**
|
|
27608
|
+
* Resets all retry and circuit breaker state to initial values
|
|
27609
|
+
*/
|
|
27610
|
+
const resetLoginState = () => {
|
|
27611
|
+
loginAttemptCount = 0;
|
|
27612
|
+
circuitBreakerOpen = false;
|
|
27613
|
+
circuitBreakerResetTime = 0;
|
|
27614
|
+
};
|
|
27615
|
+
/**
|
|
27616
|
+
* Opens the circuit breaker to prevent further login attempts
|
|
27617
|
+
*/
|
|
27618
|
+
const openCircuitBreaker = () => {
|
|
27619
|
+
circuitBreakerOpen = true;
|
|
27620
|
+
circuitBreakerResetTime = Date.now() + CIRCUIT_BREAKER_TIMEOUT_MS;
|
|
27621
|
+
loginAttemptCount = 0;
|
|
27622
|
+
console.error(`Circuit breaker opened. Login attempts blocked until ${new Date(circuitBreakerResetTime).toLocaleTimeString()}`);
|
|
27623
|
+
};
|
|
27624
|
+
/**
|
|
27625
|
+
* Checks if circuit breaker should be reset based on timeout
|
|
27626
|
+
*/
|
|
27627
|
+
const checkCircuitBreakerReset = () => {
|
|
27628
|
+
if (circuitBreakerOpen && Date.now() >= circuitBreakerResetTime) {
|
|
27629
|
+
resetLoginState();
|
|
27630
|
+
}
|
|
27631
|
+
};
|
|
27632
|
+
// ============================================================================
|
|
27633
|
+
// Authentication flow
|
|
27634
|
+
// ============================================================================
|
|
27635
|
+
/**
|
|
27636
|
+
* Internal function to ensure authentication with comprehensive retry protection
|
|
27637
|
+
*
|
|
27638
|
+
* Protection mechanisms:
|
|
27639
|
+
* - Circuit breaker: Stops all attempts after max retries for a cooldown period
|
|
27640
|
+
* - Retry limits: Maximum number of consecutive failed attempts
|
|
27641
|
+
* - Exponential backoff: Increasing delays between retries
|
|
27642
|
+
* - Rate limiting: Minimum time between attempts
|
|
27643
|
+
* - Request deduplication: Only one login attempt at a time
|
|
27644
|
+
*/
|
|
27386
27645
|
const ensureAuthenticated = async (state) => {
|
|
27387
|
-
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
27646
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
27647
|
+
// Check and potentially reset circuit breaker
|
|
27648
|
+
checkCircuitBreakerReset();
|
|
27649
|
+
// Circuit breaker check - fail fast if too many recent failures
|
|
27650
|
+
if (circuitBreakerOpen) {
|
|
27651
|
+
const waitMinutes = Math.ceil((circuitBreakerResetTime - Date.now()) / 60000);
|
|
27652
|
+
throw new LoginThrottledError(`Too many failed login attempts. Please wait ${waitMinutes} minute(s) before trying again.`);
|
|
27653
|
+
}
|
|
27388
27654
|
// If login is already in progress, wait for it to complete
|
|
27389
27655
|
if (authPromise) {
|
|
27390
27656
|
await authPromise;
|
|
27391
27657
|
return;
|
|
27392
27658
|
}
|
|
27659
|
+
// Rate limiting - prevent rapid-fire retries
|
|
27660
|
+
const timeSinceLastAttempt = Date.now() - lastLoginAttemptTime;
|
|
27661
|
+
if (timeSinceLastAttempt < MIN_LOGIN_INTERVAL_MS && loginAttemptCount > 0) {
|
|
27662
|
+
console.warn(`Login attempt too soon (${timeSinceLastAttempt}ms since last attempt)`);
|
|
27663
|
+
throw new LoginThrottledError("Please wait a moment before trying again.");
|
|
27664
|
+
}
|
|
27665
|
+
// Retry loop with exponential backoff
|
|
27393
27666
|
let continueTrying = true;
|
|
27394
|
-
while (continueTrying) {
|
|
27667
|
+
while (continueTrying && loginAttemptCount < MAX_LOGIN_RETRIES) {
|
|
27395
27668
|
continueTrying = false;
|
|
27396
|
-
|
|
27669
|
+
loginAttemptCount++;
|
|
27670
|
+
lastLoginAttemptTime = Date.now();
|
|
27671
|
+
// Apply exponential backoff for retries (skip delay on first attempt)
|
|
27672
|
+
if (loginAttemptCount >= 2) {
|
|
27673
|
+
const backoffMs = calculateBackoff(loginAttemptCount - 1);
|
|
27674
|
+
console.warn(`Login retry ${loginAttemptCount}/${MAX_LOGIN_RETRIES}, waiting ${backoffMs}ms...`);
|
|
27675
|
+
await delay(backoffMs);
|
|
27676
|
+
}
|
|
27677
|
+
// Start new login attempt
|
|
27397
27678
|
authPromise = loginUserByEmail(state);
|
|
27398
27679
|
try {
|
|
27399
27680
|
const result = await authPromise;
|
|
27681
|
+
// Handle expired token
|
|
27400
27682
|
if (result.status === "expired") {
|
|
27401
|
-
// Token has expired, notify the React component if callback is set
|
|
27402
27683
|
if (state.tokenExpired) {
|
|
27403
|
-
//
|
|
27404
|
-
|
|
27405
|
-
|
|
27406
|
-
|
|
27407
|
-
|
|
27684
|
+
// Token refresh callback is available
|
|
27685
|
+
try {
|
|
27686
|
+
// Call the token refresh callback
|
|
27687
|
+
state.tokenExpired();
|
|
27688
|
+
continueTrying = true;
|
|
27689
|
+
clearAuthSettings();
|
|
27690
|
+
continue;
|
|
27691
|
+
}
|
|
27692
|
+
catch (error) {
|
|
27693
|
+
console.error("Token refresh failed:", error);
|
|
27694
|
+
clearAuthSettings();
|
|
27695
|
+
throw new Error(`Failed to refresh authentication token: ${error instanceof Error ? error.message : String(error)}`);
|
|
27696
|
+
}
|
|
27408
27697
|
}
|
|
27409
|
-
//
|
|
27698
|
+
// No refresh callback available
|
|
27410
27699
|
clearAuthSettings();
|
|
27411
|
-
throw new Error("Authentication token has expired");
|
|
27700
|
+
throw new Error("Authentication token has expired. Please log in again.");
|
|
27412
27701
|
}
|
|
27413
|
-
|
|
27414
|
-
|
|
27702
|
+
// Handle other non-success statuses
|
|
27703
|
+
if (result.status !== "success") {
|
|
27415
27704
|
clearAuthSettings();
|
|
27416
27705
|
throw new Error(`Authentication failed: ${result.message || result.status}`);
|
|
27417
27706
|
}
|
|
27707
|
+
// ========================================================================
|
|
27708
|
+
// Success! Update global state and persist authentication
|
|
27709
|
+
// ========================================================================
|
|
27418
27710
|
// Update the user's ID to the internal one used by the backend and Firebase
|
|
27419
27711
|
state.userId = result.user_id || state.userId;
|
|
27420
27712
|
state.account = result.account || state.userId;
|
|
27421
|
-
// Update the user's nickname
|
|
27422
27713
|
state.userNick = result.nickname || state.userNick;
|
|
27423
|
-
// Use the server's Firebase API key, if provided
|
|
27424
27714
|
state.firebaseApiKey = result.firebase_api_key || state.firebaseApiKey;
|
|
27425
27715
|
// Load state flags and preferences
|
|
27426
27716
|
state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
|
|
27427
27717
|
state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
|
|
27428
27718
|
state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
|
|
27429
27719
|
state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
|
|
27430
|
-
|
|
27720
|
+
state.audio = (_k = (_j = result.prefs) === null || _j === void 0 ? void 0 : _j.audio) !== null && _k !== void 0 ? _k : false;
|
|
27721
|
+
state.fanfare = (_m = (_l = result.prefs) === null || _l === void 0 ? void 0 : _l.fanfare) !== null && _m !== void 0 ? _m : false;
|
|
27722
|
+
state.hasPaid = (_p = (_o = result.prefs) === null || _o === void 0 ? void 0 : _o.haspaid) !== null && _p !== void 0 ? _p : false;
|
|
27723
|
+
// Save authentication settings to sessionStorage
|
|
27431
27724
|
saveAuthSettings({
|
|
27432
|
-
userEmail: state.userEmail,
|
|
27725
|
+
userEmail: state.userEmail,
|
|
27433
27726
|
userId: state.userId,
|
|
27434
27727
|
userNick: state.userNick,
|
|
27435
27728
|
firebaseApiKey: state.firebaseApiKey,
|
|
@@ -27437,18 +27730,39 @@ const ensureAuthenticated = async (state) => {
|
|
|
27437
27730
|
fairPlay: state.fairPlay,
|
|
27438
27731
|
ready: state.ready,
|
|
27439
27732
|
readyTimed: state.readyTimed,
|
|
27733
|
+
audio: state.audio,
|
|
27734
|
+
fanfare: state.fanfare,
|
|
27440
27735
|
});
|
|
27441
|
-
//
|
|
27442
|
-
// user info may have changed
|
|
27736
|
+
// Redraw UI to reflect authentication changes
|
|
27443
27737
|
m.redraw();
|
|
27444
|
-
//
|
|
27738
|
+
// Log in to Firebase with the token from server
|
|
27445
27739
|
await loginFirebase(state, result.firebase_token);
|
|
27740
|
+
// Success - reset all retry state
|
|
27741
|
+
resetLoginState();
|
|
27742
|
+
}
|
|
27743
|
+
catch (error) {
|
|
27744
|
+
// On last retry, open circuit breaker
|
|
27745
|
+
if (loginAttemptCount >= MAX_LOGIN_RETRIES) {
|
|
27746
|
+
openCircuitBreaker();
|
|
27747
|
+
throw new Error(`Login failed after ${MAX_LOGIN_RETRIES} attempts. ` +
|
|
27748
|
+
`Please wait ${Math.ceil(CIRCUIT_BREAKER_TIMEOUT_MS / 60000)} minutes before trying again.`);
|
|
27749
|
+
}
|
|
27750
|
+
// Re-throw error if we're not retrying
|
|
27751
|
+
if (!continueTrying) {
|
|
27752
|
+
throw error;
|
|
27753
|
+
}
|
|
27754
|
+
// Continue to next retry iteration
|
|
27446
27755
|
}
|
|
27447
27756
|
finally {
|
|
27448
|
-
// Reset the promise so future
|
|
27757
|
+
// Reset the promise so future attempts can proceed
|
|
27449
27758
|
authPromise = null;
|
|
27450
27759
|
}
|
|
27451
27760
|
}
|
|
27761
|
+
// Should not reach here, but handle max retries edge case
|
|
27762
|
+
if (loginAttemptCount >= MAX_LOGIN_RETRIES) {
|
|
27763
|
+
openCircuitBreaker();
|
|
27764
|
+
throw new Error("Maximum login attempts exceeded. Please try again later.");
|
|
27765
|
+
}
|
|
27452
27766
|
};
|
|
27453
27767
|
// Internal authenticated request function
|
|
27454
27768
|
const authenticatedRequest = async (state, options, retries = 0) => {
|
|
@@ -27696,43 +28010,49 @@ const UserInfoButton = {
|
|
|
27696
28010
|
}, isRobot ? "" : m("span.usr-info"));
|
|
27697
28011
|
}
|
|
27698
28012
|
};
|
|
27699
|
-
const OnlinePresence =
|
|
28013
|
+
const OnlinePresence = {
|
|
27700
28014
|
// Shows an icon in grey or green depending on whether a given user
|
|
27701
28015
|
// is online or not. If attrs.online is given (i.e. not undefined),
|
|
27702
28016
|
// that value is used and displayed; otherwise the server is asked.
|
|
27703
|
-
|
|
27704
|
-
|
|
27705
|
-
|
|
27706
|
-
|
|
27707
|
-
|
|
27708
|
-
|
|
27709
|
-
|
|
27710
|
-
|
|
27711
|
-
|
|
27712
|
-
|
|
27713
|
-
|
|
27714
|
-
|
|
27715
|
-
|
|
27716
|
-
|
|
27717
|
-
|
|
27718
|
-
|
|
27719
|
-
|
|
28017
|
+
oninit: (vnode) => {
|
|
28018
|
+
const { state } = vnode;
|
|
28019
|
+
state.online = false;
|
|
28020
|
+
},
|
|
28021
|
+
oncreate: (vnode) => {
|
|
28022
|
+
// Note: we must use a two-step initialization here,
|
|
28023
|
+
// calling oninit() first and then oncreate(), since calls to
|
|
28024
|
+
// m.redraw() - explicit or implicit via m.request() - from within
|
|
28025
|
+
// oninit() will cause a recursive call to oninit(), resulting in
|
|
28026
|
+
// a double API call to the backend.
|
|
28027
|
+
const { attrs, state } = vnode;
|
|
28028
|
+
state.online = !!attrs.online;
|
|
28029
|
+
if (attrs.online === undefined) {
|
|
28030
|
+
// We need to ask the server for the online status
|
|
28031
|
+
const askServer = async () => {
|
|
28032
|
+
try {
|
|
28033
|
+
const json = await request(attrs.state, {
|
|
28034
|
+
method: "POST",
|
|
28035
|
+
url: "/onlinecheck",
|
|
28036
|
+
body: { user: attrs.userId }
|
|
28037
|
+
});
|
|
28038
|
+
state.online = json && json.online;
|
|
28039
|
+
}
|
|
28040
|
+
catch (e) {
|
|
28041
|
+
state.online = false;
|
|
28042
|
+
}
|
|
28043
|
+
};
|
|
28044
|
+
askServer(); // Fire-and-forget
|
|
27720
28045
|
}
|
|
28046
|
+
},
|
|
28047
|
+
view: (vnode) => {
|
|
28048
|
+
const { attrs, state } = vnode;
|
|
28049
|
+
const online = state.online || (!!attrs.online);
|
|
28050
|
+
return m("span", {
|
|
28051
|
+
id: attrs.id,
|
|
28052
|
+
title: online ? ts("Er álínis") : ts("Álínis?"),
|
|
28053
|
+
class: online ? "online" : ""
|
|
28054
|
+
});
|
|
27721
28055
|
}
|
|
27722
|
-
return {
|
|
27723
|
-
oninit: _update,
|
|
27724
|
-
view: (vnode) => {
|
|
27725
|
-
var _a, _b;
|
|
27726
|
-
if (!askServer)
|
|
27727
|
-
// Display the state of the online attribute as-is
|
|
27728
|
-
online = (_b = (_a = vnode.attrs) === null || _a === void 0 ? void 0 : _a.online) !== null && _b !== void 0 ? _b : false;
|
|
27729
|
-
return m("span", {
|
|
27730
|
-
id: id,
|
|
27731
|
-
title: online ? ts("Er álínis") : ts("Álínis?"),
|
|
27732
|
-
class: online ? "online" : ""
|
|
27733
|
-
});
|
|
27734
|
-
}
|
|
27735
|
-
};
|
|
27736
28056
|
};
|
|
27737
28057
|
const UserId = {
|
|
27738
28058
|
// User identifier at top right, opens user preferences
|
|
@@ -27905,11 +28225,12 @@ const TogglerReadyTimed = (initialVnode) => {
|
|
|
27905
28225
|
}
|
|
27906
28226
|
};
|
|
27907
28227
|
};
|
|
27908
|
-
const TogglerAudio = () => {
|
|
28228
|
+
const TogglerAudio = (initialVnode) => {
|
|
27909
28229
|
// Toggle for audio on/off
|
|
28230
|
+
const { model } = initialVnode.attrs.view;
|
|
27910
28231
|
function toggleFunc(state) {
|
|
27911
|
-
if (state)
|
|
27912
|
-
playAudio("your-turn");
|
|
28232
|
+
if (state && model.state !== null)
|
|
28233
|
+
playAudio(model.state, "your-turn");
|
|
27913
28234
|
}
|
|
27914
28235
|
return {
|
|
27915
28236
|
view: ({ attrs: { state, tabindex } }) => m(Toggler, {
|
|
@@ -27924,11 +28245,12 @@ const TogglerAudio = () => {
|
|
|
27924
28245
|
})
|
|
27925
28246
|
};
|
|
27926
28247
|
};
|
|
27927
|
-
const TogglerFanfare = () => {
|
|
28248
|
+
const TogglerFanfare = (initialVnode) => {
|
|
27928
28249
|
// Toggle for fanfare on/off
|
|
28250
|
+
const { model } = initialVnode.attrs.view;
|
|
27929
28251
|
function toggleFunc(state) {
|
|
27930
|
-
if (state)
|
|
27931
|
-
playAudio("you-win");
|
|
28252
|
+
if (state && model.state !== null)
|
|
28253
|
+
playAudio(model.state, "you-win");
|
|
27932
28254
|
}
|
|
27933
28255
|
return {
|
|
27934
28256
|
view: ({ attrs: { state, tabindex } }) => m(Toggler, {
|
|
@@ -27985,146 +28307,148 @@ const TogglerFairplay = () => {
|
|
|
27985
28307
|
For further information, see https://github.com/mideind/Netskrafl
|
|
27986
28308
|
|
|
27987
28309
|
*/
|
|
27988
|
-
const WaitDialog =
|
|
28310
|
+
const WaitDialog = {
|
|
27989
28311
|
// A dialog that is shown while the user waits for the opponent,
|
|
27990
28312
|
// who issued a timed game challenge, to be ready
|
|
27991
|
-
|
|
27992
|
-
|
|
27993
|
-
|
|
27994
|
-
|
|
27995
|
-
|
|
27996
|
-
|
|
27997
|
-
|
|
27998
|
-
|
|
27999
|
-
|
|
28000
|
-
|
|
28001
|
-
|
|
28002
|
-
|
|
28003
|
-
|
|
28004
|
-
|
|
28005
|
-
|
|
28006
|
-
|
|
28007
|
-
|
|
28008
|
-
|
|
28009
|
-
|
|
28010
|
-
try {
|
|
28011
|
-
if (!oppId || !key || !state)
|
|
28012
|
-
return;
|
|
28013
|
-
const json = await request(state, {
|
|
28014
|
-
method: "POST",
|
|
28015
|
-
url: "/initwait",
|
|
28016
|
-
body: { opp: oppId, key }
|
|
28017
|
-
});
|
|
28018
|
-
// If json.waiting is false, the initiation failed
|
|
28019
|
-
// and there is really no point in continuing to wait
|
|
28020
|
-
if (json && json.online && json.waiting)
|
|
28021
|
-
// The user is online
|
|
28022
|
-
oppOnline = true;
|
|
28023
|
-
}
|
|
28024
|
-
catch (e) {
|
|
28025
|
-
}
|
|
28026
|
-
}
|
|
28027
|
-
async function cancelWait() {
|
|
28028
|
-
// Cancel a pending wait for a timed game
|
|
28029
|
-
if (!state)
|
|
28030
|
-
return;
|
|
28031
|
-
try {
|
|
28032
|
-
await request(state, {
|
|
28033
|
-
method: "POST",
|
|
28034
|
-
url: "/cancelwait",
|
|
28035
|
-
body: {
|
|
28036
|
-
user: userId,
|
|
28037
|
-
opp: oppId,
|
|
28038
|
-
key
|
|
28039
|
-
}
|
|
28040
|
-
});
|
|
28041
|
-
}
|
|
28042
|
-
catch (e) {
|
|
28043
|
-
}
|
|
28044
|
-
}
|
|
28045
|
-
const oncreate = async () => {
|
|
28046
|
-
if (!userId || !oppId || initialized)
|
|
28313
|
+
oninit: (vnode) => {
|
|
28314
|
+
const { state } = vnode;
|
|
28315
|
+
state.oppOnline = false;
|
|
28316
|
+
state.pointOfNoReturn = false;
|
|
28317
|
+
state.firebasePath = "";
|
|
28318
|
+
},
|
|
28319
|
+
oncreate: (vnode) => {
|
|
28320
|
+
var _a, _b;
|
|
28321
|
+
// Note: we must use a two-step initialization here,
|
|
28322
|
+
// calling oninit() first and then oncreate(), since calls to
|
|
28323
|
+
// m.redraw() - explicit or implicit via m.request() - from within
|
|
28324
|
+
// oninit() will cause a recursive call to oninit(), resulting in
|
|
28325
|
+
// a double API call to the backend.
|
|
28326
|
+
const { attrs, state } = vnode;
|
|
28327
|
+
const { view, oppId, challengeKey: key } = attrs;
|
|
28328
|
+
const { model } = view;
|
|
28329
|
+
const globalState = model.state;
|
|
28330
|
+
const userId = (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
|
|
28331
|
+
if (!userId || !oppId)
|
|
28047
28332
|
return; // Should not happen
|
|
28048
|
-
|
|
28049
|
-
|
|
28333
|
+
state.firebasePath = `user/${userId}/wait/${oppId}`;
|
|
28334
|
+
// Initiate an online check on the opponent (fire-and-forget)
|
|
28335
|
+
const updateOnline = async () => {
|
|
28336
|
+
try {
|
|
28337
|
+
if (key && globalState) {
|
|
28338
|
+
const json = await request(globalState, {
|
|
28339
|
+
method: "POST",
|
|
28340
|
+
url: "/initwait",
|
|
28341
|
+
body: { opp: oppId, key }
|
|
28342
|
+
});
|
|
28343
|
+
// If json.waiting is false, the initiation failed
|
|
28344
|
+
// and there is really no point in continuing to wait
|
|
28345
|
+
if (json && json.online && json.waiting) {
|
|
28346
|
+
// The user is online
|
|
28347
|
+
state.oppOnline = true;
|
|
28348
|
+
}
|
|
28349
|
+
}
|
|
28350
|
+
}
|
|
28351
|
+
catch (e) {
|
|
28352
|
+
}
|
|
28353
|
+
};
|
|
28354
|
+
updateOnline(); // Fire-and-forget, don't await
|
|
28050
28355
|
// Attach a Firebase listener to the wait path
|
|
28051
|
-
attachFirebaseListener(
|
|
28356
|
+
attachFirebaseListener(state.firebasePath, (json) => {
|
|
28052
28357
|
if (json !== true && json.game) {
|
|
28053
28358
|
// A new game has been created and initiated by the server
|
|
28054
|
-
pointOfNoReturn = true;
|
|
28055
|
-
detachFirebaseListener(
|
|
28359
|
+
state.pointOfNoReturn = true;
|
|
28360
|
+
detachFirebaseListener(state.firebasePath);
|
|
28056
28361
|
// We don't need to pop the dialog; that is done automatically
|
|
28057
28362
|
// by the route resolver upon m.route.set()
|
|
28058
28363
|
// Navigate to the newly initiated game
|
|
28059
28364
|
m.route.set("/game/" + json.game);
|
|
28060
28365
|
}
|
|
28061
28366
|
});
|
|
28062
|
-
}
|
|
28063
|
-
|
|
28064
|
-
|
|
28065
|
-
|
|
28066
|
-
|
|
28067
|
-
|
|
28068
|
-
|
|
28069
|
-
|
|
28070
|
-
|
|
28071
|
-
|
|
28072
|
-
|
|
28073
|
-
|
|
28074
|
-
|
|
28075
|
-
|
|
28076
|
-
|
|
28077
|
-
|
|
28078
|
-
|
|
28079
|
-
|
|
28080
|
-
|
|
28081
|
-
|
|
28082
|
-
|
|
28083
|
-
|
|
28084
|
-
|
|
28085
|
-
|
|
28086
|
-
|
|
28087
|
-
|
|
28367
|
+
},
|
|
28368
|
+
view: (vnode) => {
|
|
28369
|
+
var _a, _b;
|
|
28370
|
+
const { attrs, state } = vnode;
|
|
28371
|
+
const { view, oppId, duration, challengeKey: key, oppNick, oppName } = attrs;
|
|
28372
|
+
const { model } = view;
|
|
28373
|
+
const globalState = model.state;
|
|
28374
|
+
const userId = (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
|
|
28375
|
+
if (!globalState)
|
|
28376
|
+
return null;
|
|
28377
|
+
const cancelWait = async () => {
|
|
28378
|
+
// Cancel a pending wait for a timed game
|
|
28379
|
+
if (!globalState)
|
|
28380
|
+
return;
|
|
28381
|
+
try {
|
|
28382
|
+
request(globalState, {
|
|
28383
|
+
method: "POST",
|
|
28384
|
+
url: "/cancelwait",
|
|
28385
|
+
body: {
|
|
28386
|
+
user: userId,
|
|
28387
|
+
opp: oppId,
|
|
28388
|
+
key
|
|
28389
|
+
}
|
|
28390
|
+
});
|
|
28391
|
+
}
|
|
28392
|
+
catch (e) {
|
|
28393
|
+
}
|
|
28394
|
+
};
|
|
28395
|
+
return m(".modal-dialog", { id: "wait-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { "id": "wait-form" }, [
|
|
28396
|
+
m(".chall-hdr", m("table", m("tbody", m("tr", [
|
|
28397
|
+
m("td", m("h1.chall-icon", glyph("time"))),
|
|
28398
|
+
m("td.l-border", [
|
|
28399
|
+
m(OnlinePresence, { id: "chall-online", userId: oppId, online: state.oppOnline, state: globalState }),
|
|
28400
|
+
m("h1", oppNick),
|
|
28401
|
+
m("h2", oppName),
|
|
28402
|
+
])
|
|
28403
|
+
])))),
|
|
28404
|
+
m(".wait-explain", [
|
|
28405
|
+
mt("p", [
|
|
28406
|
+
"Þú ert reiðubúin(n) að taka áskorun um viðureign með klukku,",
|
|
28088
28407
|
]),
|
|
28089
|
-
m(
|
|
28090
|
-
|
|
28091
|
-
|
|
28092
|
-
|
|
28093
|
-
|
|
28094
|
-
|
|
28095
|
-
|
|
28096
|
-
|
|
28097
|
-
|
|
28098
|
-
|
|
28099
|
-
|
|
28100
|
-
|
|
28101
|
-
|
|
28408
|
+
m("p", [
|
|
28409
|
+
m("strong", ["2 x ", duration.toString(), ts(" mínútur.")])
|
|
28410
|
+
]),
|
|
28411
|
+
mt("p", [
|
|
28412
|
+
"Beðið er eftir að áskorandinn ", m("strong", oppNick),
|
|
28413
|
+
" sé ", state.oppOnline ? "" : mt("span#chall-is-online", "álínis og "), "til í tuskið."
|
|
28414
|
+
]),
|
|
28415
|
+
mt("p", "Leikur hefst um leið og áskorandinn bregst við. Handahóf ræður hvor byrjar."),
|
|
28416
|
+
mt("p", "Ef þér leiðist biðin geturðu hætt við og reynt aftur síðar.")
|
|
28417
|
+
]),
|
|
28418
|
+
m(DialogButton, {
|
|
28419
|
+
id: "wait-cancel",
|
|
28420
|
+
title: ts("Hætta við"),
|
|
28421
|
+
onclick: (ev) => {
|
|
28422
|
+
// Cancel the wait status and navigate back to the main page
|
|
28423
|
+
if (state.pointOfNoReturn) {
|
|
28424
|
+
// Actually, it's too late to cancel
|
|
28102
28425
|
ev.preventDefault();
|
|
28426
|
+
return;
|
|
28103
28427
|
}
|
|
28104
|
-
|
|
28105
|
-
|
|
28106
|
-
|
|
28107
|
-
|
|
28428
|
+
if (state.firebasePath)
|
|
28429
|
+
detachFirebaseListener(state.firebasePath);
|
|
28430
|
+
cancelWait();
|
|
28431
|
+
view.popDialog();
|
|
28432
|
+
ev.preventDefault();
|
|
28433
|
+
}
|
|
28434
|
+
}, glyph("remove"))
|
|
28435
|
+
]));
|
|
28436
|
+
}
|
|
28108
28437
|
};
|
|
28109
|
-
const AcceptDialog =
|
|
28438
|
+
const AcceptDialog = {
|
|
28110
28439
|
// A dialog that is shown (usually briefly) while
|
|
28111
28440
|
// the user who originated a timed game challenge
|
|
28112
28441
|
// is linked up with her opponent and a new game is started
|
|
28113
|
-
|
|
28114
|
-
|
|
28115
|
-
|
|
28116
|
-
|
|
28117
|
-
|
|
28118
|
-
|
|
28119
|
-
|
|
28120
|
-
let loading = false;
|
|
28121
|
-
async function waitCheck() {
|
|
28442
|
+
oninit: async (vnode) => {
|
|
28443
|
+
const { attrs, state } = vnode;
|
|
28444
|
+
const { view, oppId, challengeKey: key } = attrs;
|
|
28445
|
+
const globalState = view.model.state;
|
|
28446
|
+
if (!globalState)
|
|
28447
|
+
return; // Should not happen
|
|
28448
|
+
state.oppReady = true;
|
|
28122
28449
|
// Initiate a wait status check on the opponent
|
|
28123
|
-
if (loading || !state)
|
|
28124
|
-
return; // Already checking
|
|
28125
|
-
loading = true;
|
|
28126
28450
|
try {
|
|
28127
|
-
const json = await request(
|
|
28451
|
+
const json = await request(globalState, {
|
|
28128
28452
|
method: "POST",
|
|
28129
28453
|
url: "/waitcheck",
|
|
28130
28454
|
body: { user: oppId, key }
|
|
@@ -28135,42 +28459,40 @@ const AcceptDialog = (initialVnode) => {
|
|
|
28135
28459
|
// and all open dialogs are thereby closed automatically.
|
|
28136
28460
|
view.actions.startNewGame(oppId, true);
|
|
28137
28461
|
}
|
|
28138
|
-
else
|
|
28462
|
+
else {
|
|
28139
28463
|
// Something didn't check out: keep the dialog open
|
|
28140
28464
|
// until the user manually closes it
|
|
28141
|
-
oppReady = false;
|
|
28465
|
+
state.oppReady = false;
|
|
28466
|
+
}
|
|
28142
28467
|
}
|
|
28143
28468
|
catch (e) {
|
|
28469
|
+
state.oppReady = false;
|
|
28144
28470
|
}
|
|
28145
|
-
|
|
28146
|
-
|
|
28147
|
-
}
|
|
28471
|
+
},
|
|
28472
|
+
view: (vnode) => {
|
|
28473
|
+
const { attrs, state } = vnode;
|
|
28474
|
+
const { view, oppNick } = attrs;
|
|
28475
|
+
return m(".modal-dialog", { id: "accept-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" }, [
|
|
28476
|
+
m(".chall-hdr", m("table", m("tbody", m("tr", [
|
|
28477
|
+
m("td", m("h1.chall-icon", glyph("time"))),
|
|
28478
|
+
m("td.l-border", m("h1", oppNick))
|
|
28479
|
+
])))),
|
|
28480
|
+
m("div", { "style": { "text-align": "center", "padding-top": "32px" } }, [
|
|
28481
|
+
m("p", mt("strong", "Viðureign með klukku")),
|
|
28482
|
+
mt("p", state.oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
|
|
28483
|
+
: ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"])
|
|
28484
|
+
]),
|
|
28485
|
+
m(DialogButton, {
|
|
28486
|
+
id: 'accept-cancel',
|
|
28487
|
+
title: ts('Reyna síðar'),
|
|
28488
|
+
onclick: (ev) => {
|
|
28489
|
+
// Abort mission
|
|
28490
|
+
view.popDialog();
|
|
28491
|
+
ev.preventDefault();
|
|
28492
|
+
}
|
|
28493
|
+
}, glyph("remove"))
|
|
28494
|
+
]));
|
|
28148
28495
|
}
|
|
28149
|
-
return {
|
|
28150
|
-
oninit: waitCheck,
|
|
28151
|
-
view: () => {
|
|
28152
|
-
return m(".modal-dialog", { id: "accept-dialog", style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" }, [
|
|
28153
|
-
m(".chall-hdr", m("table", m("tbody", m("tr", [
|
|
28154
|
-
m("td", m("h1.chall-icon", glyph("time"))),
|
|
28155
|
-
m("td.l-border", m("h1", oppNick))
|
|
28156
|
-
])))),
|
|
28157
|
-
m("div", { "style": { "text-align": "center", "padding-top": "32px" } }, [
|
|
28158
|
-
m("p", mt("strong", "Viðureign með klukku")),
|
|
28159
|
-
mt("p", oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
|
|
28160
|
-
: ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"])
|
|
28161
|
-
]),
|
|
28162
|
-
m(DialogButton, {
|
|
28163
|
-
id: 'accept-cancel',
|
|
28164
|
-
title: ts('Reyna síðar'),
|
|
28165
|
-
onclick: (ev) => {
|
|
28166
|
-
// Abort mission
|
|
28167
|
-
view.popDialog();
|
|
28168
|
-
ev.preventDefault();
|
|
28169
|
-
}
|
|
28170
|
-
}, glyph("remove"))
|
|
28171
|
-
]));
|
|
28172
|
-
}
|
|
28173
|
-
};
|
|
28174
28496
|
};
|
|
28175
28497
|
|
|
28176
28498
|
/*
|
|
@@ -29075,8 +29397,7 @@ class Game extends BaseGame {
|
|
|
29075
29397
|
// Ongoing timed game: start the clock
|
|
29076
29398
|
this.startClock();
|
|
29077
29399
|
// Kick off loading of chat messages, if this is not a robot game
|
|
29078
|
-
|
|
29079
|
-
if (isHumanGame)
|
|
29400
|
+
if (!this.isRobotGame())
|
|
29080
29401
|
this.loadMessages();
|
|
29081
29402
|
}
|
|
29082
29403
|
init(srvGame) {
|
|
@@ -29234,6 +29555,8 @@ class Game extends BaseGame {
|
|
|
29234
29555
|
// Update the srvGame state with data from the server,
|
|
29235
29556
|
// either after submitting a move to the server or
|
|
29236
29557
|
// after receiving a move notification via the Firebase listener
|
|
29558
|
+
// Remember if the game was already won before this update
|
|
29559
|
+
const wasWon = this.congratulate;
|
|
29237
29560
|
// Stop highlighting the previous opponent move, if any
|
|
29238
29561
|
for (let sq in this.tiles)
|
|
29239
29562
|
if (this.tiles.hasOwnProperty(sq))
|
|
@@ -29254,6 +29577,10 @@ class Game extends BaseGame {
|
|
|
29254
29577
|
// The call to resetClock() clears any outstanding interval timers
|
|
29255
29578
|
// if the srvGame is now over
|
|
29256
29579
|
this.resetClock();
|
|
29580
|
+
// Notify the move listener if the game just transitioned to won
|
|
29581
|
+
if (!wasWon && this.congratulate && this.moveListener) {
|
|
29582
|
+
this.moveListener.notifyGameWon();
|
|
29583
|
+
}
|
|
29257
29584
|
}
|
|
29258
29585
|
;
|
|
29259
29586
|
async refresh() {
|
|
@@ -29458,6 +29785,10 @@ class Game extends BaseGame {
|
|
|
29458
29785
|
// actual game score minus accrued time penalty, if any, in a timed game
|
|
29459
29786
|
return Math.max(this.scores[player] + (player === 0 ? this.penalty0 : this.penalty1), 0);
|
|
29460
29787
|
}
|
|
29788
|
+
isRobotGame() {
|
|
29789
|
+
// Return true if any player in the game is a robot
|
|
29790
|
+
return this.autoplayer[0] || this.autoplayer[1];
|
|
29791
|
+
}
|
|
29461
29792
|
async loadMessages() {
|
|
29462
29793
|
// Load chat messages for this game
|
|
29463
29794
|
if (this.chatLoading)
|
|
@@ -30942,60 +31273,48 @@ class Model {
|
|
|
30942
31273
|
}
|
|
30943
31274
|
return false;
|
|
30944
31275
|
}
|
|
30945
|
-
|
|
30946
|
-
// Handle an incoming Firebase user message,
|
|
30947
|
-
// on the /user/[userid] path
|
|
31276
|
+
handleUserChallengeMessage(json, firstAttach) {
|
|
31277
|
+
// Handle an incoming Firebase user challenge message,
|
|
31278
|
+
// i.e. a message on the /user/[userid]/challenge path
|
|
30948
31279
|
if (firstAttach || !this.state || !json)
|
|
30949
31280
|
return;
|
|
30950
|
-
|
|
30951
|
-
|
|
30952
|
-
|
|
30953
|
-
|
|
30954
|
-
|
|
30955
|
-
|
|
30956
|
-
|
|
30957
|
-
|
|
30958
|
-
|
|
30959
|
-
|
|
30960
|
-
const newHasPaid = (this.state.plan !== "" && json.hasPaid) ? true : false;
|
|
30961
|
-
if (this.state.hasPaid !== newHasPaid) {
|
|
30962
|
-
this.state.hasPaid = newHasPaid;
|
|
30963
|
-
redraw = true;
|
|
30964
|
-
}
|
|
30965
|
-
}
|
|
30966
|
-
let invalidateGameList = false;
|
|
30967
|
-
// The following code is a bit iffy since both json.challenge and json.move
|
|
30968
|
-
// are included in the same message on the /user/[userid] path.
|
|
30969
|
-
// !!! FIXME: Split this into two separate listeners,
|
|
30970
|
-
// !!! one for challenges and one for moves
|
|
30971
|
-
if (json.challenge) {
|
|
30972
|
-
// Reload challenge list
|
|
30973
|
-
this.loadChallengeList();
|
|
30974
|
-
if (this.userListCriteria)
|
|
30975
|
-
// We are showing a user list: reload it
|
|
30976
|
-
this.loadUserList(this.userListCriteria);
|
|
30977
|
-
// Reload game list
|
|
30978
|
-
// !!! FIXME: It is strictly speaking not necessary to reload
|
|
30979
|
-
// !!! the game list unless this is an acceptance of a challenge
|
|
30980
|
-
// !!! (issuance or rejection don't cause the game list to change)
|
|
30981
|
-
invalidateGameList = true;
|
|
30982
|
-
}
|
|
30983
|
-
else if (json.move) {
|
|
30984
|
-
// A move has been made in one of this user's games:
|
|
30985
|
-
// invalidate the game list (will be loaded upon next display)
|
|
30986
|
-
invalidateGameList = true;
|
|
30987
|
-
}
|
|
30988
|
-
if (invalidateGameList && !this.loadingGameList) {
|
|
31281
|
+
// Reload challenge list
|
|
31282
|
+
this.loadChallengeList();
|
|
31283
|
+
if (this.userListCriteria)
|
|
31284
|
+
// We are showing a user list: reload it
|
|
31285
|
+
this.loadUserList(this.userListCriteria);
|
|
31286
|
+
// Reload game list
|
|
31287
|
+
// !!! FIXME: It is strictly speaking not necessary to reload
|
|
31288
|
+
// !!! the game list unless this is an acceptance of a challenge
|
|
31289
|
+
// !!! (issuance or rejection don't cause the game list to change)
|
|
31290
|
+
if (!this.loadingGameList) {
|
|
30989
31291
|
this.gameList = null;
|
|
30990
|
-
redraw = true;
|
|
30991
31292
|
}
|
|
30992
|
-
|
|
31293
|
+
m.redraw();
|
|
31294
|
+
}
|
|
31295
|
+
handleUserMoveMessage(json, firstAttach) {
|
|
31296
|
+
// Handle an incoming Firebase user move message,
|
|
31297
|
+
// i.e. a message on the /user/[userid]/move path
|
|
31298
|
+
if (firstAttach || !this.state || !json)
|
|
31299
|
+
return;
|
|
31300
|
+
// A move has been made in one of this user's games:
|
|
31301
|
+
// invalidate the game list (will be loaded upon next display)
|
|
31302
|
+
if (!this.loadingGameList) {
|
|
31303
|
+
this.gameList = null;
|
|
30993
31304
|
m.redraw();
|
|
31305
|
+
}
|
|
30994
31306
|
}
|
|
30995
31307
|
handleMoveMessage(json, firstAttach) {
|
|
30996
31308
|
// Handle an incoming Firebase move message
|
|
30997
31309
|
if (!firstAttach && this.game) {
|
|
30998
31310
|
this.game.update(json);
|
|
31311
|
+
// Play "your turn" audio notification if:
|
|
31312
|
+
// - User has audio enabled
|
|
31313
|
+
// - User is a participant in the game
|
|
31314
|
+
// - This is not a robot game (robots reply instantly anyway)
|
|
31315
|
+
if (this.state.audio && this.game.player !== null && !this.game.isRobotGame()) {
|
|
31316
|
+
playAudio(this.state, "your-turn");
|
|
31317
|
+
}
|
|
30999
31318
|
m.redraw();
|
|
31000
31319
|
}
|
|
31001
31320
|
}
|
|
@@ -31006,6 +31325,14 @@ class Model {
|
|
|
31006
31325
|
this.gameList = null;
|
|
31007
31326
|
}
|
|
31008
31327
|
}
|
|
31328
|
+
notifyGameWon() {
|
|
31329
|
+
var _a;
|
|
31330
|
+
// The user just won a game:
|
|
31331
|
+
// play the "you-win" audio if fanfare is enabled
|
|
31332
|
+
if ((_a = this.user) === null || _a === void 0 ? void 0 : _a.fanfare) {
|
|
31333
|
+
playAudio(this.state, "you-win");
|
|
31334
|
+
}
|
|
31335
|
+
}
|
|
31009
31336
|
moreGamesAllowed() {
|
|
31010
31337
|
// Return true if the user is allowed to have more games ongoing
|
|
31011
31338
|
if (!this.state)
|
|
@@ -32428,117 +32755,128 @@ const Main = () => {
|
|
|
32428
32755
|
For further information, see https://github.com/mideind/Netskrafl
|
|
32429
32756
|
|
|
32430
32757
|
*/
|
|
32431
|
-
const
|
|
32758
|
+
const _updateStats = (attrs, state) => {
|
|
32759
|
+
// Fetch the statistics of the given user
|
|
32760
|
+
if (state.loadingStats)
|
|
32761
|
+
return;
|
|
32762
|
+
state.loadingStats = true;
|
|
32763
|
+
const { model } = attrs.view;
|
|
32764
|
+
model.loadUserStats(attrs.userid, (json) => {
|
|
32765
|
+
if (json && json.result === 0)
|
|
32766
|
+
state.stats = json;
|
|
32767
|
+
else
|
|
32768
|
+
state.stats = {};
|
|
32769
|
+
state.loadingStats = false;
|
|
32770
|
+
// m.redraw();
|
|
32771
|
+
});
|
|
32772
|
+
};
|
|
32773
|
+
const _updateRecentList = (attrs, state) => {
|
|
32774
|
+
var _a, _b;
|
|
32775
|
+
// Fetch the recent game list of the given user
|
|
32776
|
+
if (state.loadingRecentList)
|
|
32777
|
+
return;
|
|
32778
|
+
state.loadingRecentList = true;
|
|
32779
|
+
const { model } = attrs.view;
|
|
32780
|
+
model.loadUserRecentList(attrs.userid, state.versusAll ? null : (_b = (_a = model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "", (json) => {
|
|
32781
|
+
if (json && json.result === 0)
|
|
32782
|
+
state.recentList = json.recentlist;
|
|
32783
|
+
else
|
|
32784
|
+
state.recentList = [];
|
|
32785
|
+
state.loadingRecentList = false;
|
|
32786
|
+
// m.redraw();
|
|
32787
|
+
});
|
|
32788
|
+
};
|
|
32789
|
+
const _setVersus = (attrs, state, vsState) => {
|
|
32790
|
+
if (state.versusAll != vsState) {
|
|
32791
|
+
state.versusAll = vsState;
|
|
32792
|
+
state.loadingRecentList = false;
|
|
32793
|
+
_updateRecentList(attrs, state);
|
|
32794
|
+
}
|
|
32795
|
+
};
|
|
32796
|
+
const UserInfoDialog = {
|
|
32432
32797
|
// A dialog showing the track record of a given user, including
|
|
32433
32798
|
// recent games and total statistics
|
|
32799
|
+
/*
|
|
32434
32800
|
const view = initialVnode.attrs.view;
|
|
32435
32801
|
const model = view.model;
|
|
32436
|
-
let stats = {};
|
|
32437
|
-
let recentList = [];
|
|
32802
|
+
let stats: UserStats = {};
|
|
32803
|
+
let recentList: RecentListItem[] = [];
|
|
32438
32804
|
let versusAll = true; // Show games against all opponents or just the current user?
|
|
32439
32805
|
let loadingStats = false;
|
|
32440
32806
|
let loadingRecentList = false;
|
|
32441
|
-
|
|
32442
|
-
|
|
32443
|
-
|
|
32444
|
-
|
|
32445
|
-
|
|
32446
|
-
|
|
32447
|
-
|
|
32448
|
-
|
|
32449
|
-
|
|
32450
|
-
|
|
32451
|
-
|
|
32452
|
-
|
|
32453
|
-
}
|
|
32454
|
-
|
|
32455
|
-
|
|
32456
|
-
|
|
32457
|
-
|
|
32458
|
-
|
|
32459
|
-
|
|
32460
|
-
|
|
32461
|
-
|
|
32462
|
-
if (json && json.result === 0)
|
|
32463
|
-
recentList = json.recentlist;
|
|
32464
|
-
else
|
|
32465
|
-
recentList = [];
|
|
32466
|
-
loadingRecentList = false;
|
|
32467
|
-
// m.redraw();
|
|
32468
|
-
});
|
|
32469
|
-
}
|
|
32470
|
-
function _setVersus(vnode, vsState) {
|
|
32471
|
-
if (versusAll != vsState) {
|
|
32472
|
-
versusAll = vsState;
|
|
32473
|
-
loadingRecentList = false;
|
|
32474
|
-
_updateRecentList(vnode);
|
|
32475
|
-
}
|
|
32476
|
-
}
|
|
32477
|
-
return {
|
|
32478
|
-
oninit: (vnode) => {
|
|
32479
|
-
_updateRecentList(vnode);
|
|
32480
|
-
_updateStats(vnode);
|
|
32481
|
-
},
|
|
32482
|
-
view: (vnode) => {
|
|
32483
|
-
return m(".modal-dialog", { id: 'usr-info-dialog', style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'usr-info-form' }, [
|
|
32484
|
-
m(".usr-info-hdr", [
|
|
32485
|
-
m("h1.usr-info-icon", [
|
|
32486
|
-
stats.friend ?
|
|
32487
|
-
glyph("coffee-cup", { title: ts('Áskrifandi') }) :
|
|
32488
|
-
glyph("user"), nbsp()
|
|
32489
|
-
]),
|
|
32490
|
-
m("h1[id='usr-info-nick']", vnode.attrs.nick),
|
|
32491
|
-
m("span.vbar", "|"),
|
|
32492
|
-
m("h2[id='usr-info-fullname']", vnode.attrs.fullname),
|
|
32493
|
-
m(".usr-info-fav", {
|
|
32494
|
-
title: ts('Uppáhald'),
|
|
32495
|
-
onclick: (ev) => {
|
|
32496
|
-
var _a;
|
|
32497
|
-
// Toggle the favorite setting
|
|
32498
|
-
ev.preventDefault();
|
|
32499
|
-
view.actions.toggleFavorite(vnode.attrs.userid, (_a = stats.favorite) !== null && _a !== void 0 ? _a : false);
|
|
32500
|
-
stats.favorite = !stats.favorite;
|
|
32501
|
-
}
|
|
32502
|
-
}, stats.favorite ? glyph("star") : glyph("star-empty"))
|
|
32503
|
-
]),
|
|
32504
|
-
m("p", [
|
|
32505
|
-
m("strong", t("Nýjustu viðureignir")),
|
|
32506
|
-
nbsp(),
|
|
32507
|
-
m("span.versus-cat", [
|
|
32508
|
-
m("span", {
|
|
32509
|
-
class: versusAll ? "shown" : "",
|
|
32510
|
-
onclick: () => { _setVersus(vnode, true); } // Set this.versusAll to true
|
|
32511
|
-
}, t(" gegn öllum ")),
|
|
32512
|
-
m("span", {
|
|
32513
|
-
class: versusAll ? "" : "shown",
|
|
32514
|
-
onclick: () => { _setVersus(vnode, false); } // Set this.versusAll to false
|
|
32515
|
-
}, t(" gegn þér "))
|
|
32516
|
-
])
|
|
32807
|
+
*/
|
|
32808
|
+
oninit: (vnode) => {
|
|
32809
|
+
const { attrs, state } = vnode;
|
|
32810
|
+
state.stats = {};
|
|
32811
|
+
state.recentList = [];
|
|
32812
|
+
state.versusAll = true;
|
|
32813
|
+
state.loadingRecentList = false;
|
|
32814
|
+
state.loadingStats = false;
|
|
32815
|
+
_updateRecentList(attrs, state);
|
|
32816
|
+
_updateStats(attrs, state);
|
|
32817
|
+
},
|
|
32818
|
+
view: (vnode) => {
|
|
32819
|
+
const { attrs, state } = vnode;
|
|
32820
|
+
const { view } = attrs;
|
|
32821
|
+
const { stats, recentList, versusAll } = state;
|
|
32822
|
+
return m(".modal-dialog", { id: 'usr-info-dialog', style: { visibility: "visible" } }, m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'usr-info-form' }, [
|
|
32823
|
+
m(".usr-info-hdr", [
|
|
32824
|
+
m("h1.usr-info-icon", [
|
|
32825
|
+
vnode.state.stats.friend ?
|
|
32826
|
+
glyph("coffee-cup", { title: ts('Áskrifandi') }) :
|
|
32827
|
+
glyph("user"), nbsp()
|
|
32517
32828
|
]),
|
|
32518
|
-
m(".
|
|
32519
|
-
|
|
32520
|
-
|
|
32521
|
-
|
|
32522
|
-
|
|
32523
|
-
|
|
32524
|
-
|
|
32525
|
-
|
|
32526
|
-
|
|
32527
|
-
|
|
32528
|
-
|
|
32529
|
-
|
|
32829
|
+
m("h1[id='usr-info-nick']", vnode.attrs.nick),
|
|
32830
|
+
m("span.vbar", "|"),
|
|
32831
|
+
m("h2[id='usr-info-fullname']", vnode.attrs.fullname),
|
|
32832
|
+
m(".usr-info-fav", {
|
|
32833
|
+
title: ts('Uppáhald'),
|
|
32834
|
+
onclick: (ev) => {
|
|
32835
|
+
var _a;
|
|
32836
|
+
// Toggle the favorite setting
|
|
32837
|
+
ev.preventDefault();
|
|
32838
|
+
view.actions.toggleFavorite(vnode.attrs.userid, (_a = stats.favorite) !== null && _a !== void 0 ? _a : false);
|
|
32839
|
+
stats.favorite = !stats.favorite;
|
|
32840
|
+
}
|
|
32841
|
+
}, stats.favorite ? glyph("star") : glyph("star-empty"))
|
|
32842
|
+
]),
|
|
32843
|
+
m("p", [
|
|
32844
|
+
m("strong", t("Nýjustu viðureignir")),
|
|
32845
|
+
nbsp(),
|
|
32846
|
+
m("span.versus-cat", [
|
|
32847
|
+
m("span", {
|
|
32848
|
+
class: versusAll ? "shown" : "",
|
|
32849
|
+
onclick: () => { _setVersus(attrs, state, true); } // Set this.versusAll to true
|
|
32850
|
+
}, t(" gegn öllum ")),
|
|
32851
|
+
m("span", {
|
|
32852
|
+
class: versusAll ? "" : "shown",
|
|
32853
|
+
onclick: () => { _setVersus(attrs, state, false); } // Set this.versusAll to false
|
|
32854
|
+
}, t(" gegn þér "))
|
|
32855
|
+
])
|
|
32856
|
+
]),
|
|
32857
|
+
m(".listitem.listheader", [
|
|
32858
|
+
m("span.list-win", glyphGrayed("bookmark", { title: ts('Sigur') })),
|
|
32859
|
+
mt("span.list-ts-short", "Viðureign lauk"),
|
|
32860
|
+
mt("span.list-nick", "Andstæðingur"),
|
|
32861
|
+
mt("span.list-scorehdr", "Úrslit"),
|
|
32862
|
+
m("span.list-elo-hdr", [
|
|
32863
|
+
m("span.glyphicon.glyphicon-user.elo-hdr-left", { title: ts('Mennskir andstæðingar') }),
|
|
32864
|
+
"Elo",
|
|
32865
|
+
m("span.glyphicon.glyphicon-cog.elo-hdr-right", { title: ts('Allir andstæðingar') })
|
|
32530
32866
|
]),
|
|
32531
|
-
|
|
32532
|
-
m(
|
|
32533
|
-
|
|
32534
|
-
|
|
32535
|
-
|
|
32536
|
-
|
|
32537
|
-
|
|
32538
|
-
|
|
32539
|
-
|
|
32540
|
-
|
|
32541
|
-
|
|
32867
|
+
mt("span.list-duration", "Lengd"),
|
|
32868
|
+
m("span.list-manual", glyphGrayed("lightbulb", { title: ts('Keppnishamur') }))
|
|
32869
|
+
]),
|
|
32870
|
+
m(RecentList, { view, id: 'usr-recent', recentList }), // Recent game list
|
|
32871
|
+
m(StatsDisplay, { view, id: 'usr-stats', ownStats: stats }),
|
|
32872
|
+
m(BestDisplay, { id: 'usr-best', ownStats: stats, myself: false }), // Highest word and game scores
|
|
32873
|
+
m(DialogButton, {
|
|
32874
|
+
id: 'usr-info-close',
|
|
32875
|
+
title: ts('Loka'),
|
|
32876
|
+
onclick: (ev) => { view.popDialog(); ev.preventDefault(); }
|
|
32877
|
+
}, glyph("ok"))
|
|
32878
|
+
]));
|
|
32879
|
+
},
|
|
32542
32880
|
};
|
|
32543
32881
|
|
|
32544
32882
|
/*
|
|
@@ -32717,7 +33055,7 @@ const GamePromptDialogs = (initialVnode) => {
|
|
|
32717
33055
|
// they can be invoked while the last_chall dialog is being
|
|
32718
33056
|
// displayed. We therefore allow them to cover the last_chall
|
|
32719
33057
|
// dialog. On mobile, both dialogs are displayed simultaneously.
|
|
32720
|
-
if (game.last_chall) {
|
|
33058
|
+
if (game.last_chall && game.localturn) {
|
|
32721
33059
|
r.push(m(".chall-info", [
|
|
32722
33060
|
glyph("info-sign"), nbsp(),
|
|
32723
33061
|
// "Your opponent emptied the rack - you can challenge or pass"
|
|
@@ -34334,7 +34672,7 @@ const Tab = {
|
|
|
34334
34672
|
const game = view.model.game;
|
|
34335
34673
|
return m(".right-tab" + (sel === tabid ? ".selected" : ""), {
|
|
34336
34674
|
id: "tab-" + tabid,
|
|
34337
|
-
className: alert ? "alert" : "",
|
|
34675
|
+
className: alert ? "chat-alert" : "",
|
|
34338
34676
|
title: title,
|
|
34339
34677
|
onclick: (ev) => {
|
|
34340
34678
|
// Select this tab
|
|
@@ -34356,7 +34694,7 @@ const TabGroup = {
|
|
|
34356
34694
|
// A group of clickable tabs for the right-side area content
|
|
34357
34695
|
const { view } = vnode.attrs;
|
|
34358
34696
|
const { game } = view.model;
|
|
34359
|
-
const showChat = game && !
|
|
34697
|
+
const showChat = game && !game.isRobotGame();
|
|
34360
34698
|
const r = [
|
|
34361
34699
|
m(Tab, { view, tabid: "board", title: ts("Borðið"), icon: "grid" }),
|
|
34362
34700
|
m(Tab, { view, tabid: "movelist", title: ts("Leikir"), icon: "show-lines" }),
|
|
@@ -34378,7 +34716,7 @@ const TabGroup = {
|
|
|
34378
34716
|
},
|
|
34379
34717
|
// Show chat icon in red if any chat messages have not been seen
|
|
34380
34718
|
// and the chat tab is not already selected
|
|
34381
|
-
alert: !game.chatSeen && view.selectedTab
|
|
34719
|
+
alert: !game.chatSeen && view.selectedTab !== "chat"
|
|
34382
34720
|
}));
|
|
34383
34721
|
}
|
|
34384
34722
|
return m.fragment({}, r);
|
|
@@ -35080,6 +35418,18 @@ class View {
|
|
|
35080
35418
|
this.actions = actions;
|
|
35081
35419
|
// Initialize media listeners now that we have the view reference
|
|
35082
35420
|
this.actions.initMediaListener(this);
|
|
35421
|
+
// Set up callback to attach user listener when Firebase is ready
|
|
35422
|
+
// This handles both fresh login and cached authentication cases
|
|
35423
|
+
const state = this.model.state;
|
|
35424
|
+
if (state) {
|
|
35425
|
+
state.onFirebaseReady = () => {
|
|
35426
|
+
// Firebase Database is now initialized, attach user listener
|
|
35427
|
+
this.actions.attachListenerToUser();
|
|
35428
|
+
};
|
|
35429
|
+
}
|
|
35430
|
+
// Load user preferences early so audio settings are available
|
|
35431
|
+
// Use false to not show spinner on initial load
|
|
35432
|
+
this.model.loadUser(false);
|
|
35083
35433
|
}
|
|
35084
35434
|
appView(routeName) {
|
|
35085
35435
|
// Returns a view based on the current route.
|
|
@@ -35412,8 +35762,8 @@ View.dialogViews = {
|
|
|
35412
35762
|
class Actions {
|
|
35413
35763
|
constructor(model) {
|
|
35414
35764
|
this.model = model;
|
|
35415
|
-
// Media listeners will be initialized
|
|
35416
|
-
//
|
|
35765
|
+
// Media and Firebase listeners will be initialized
|
|
35766
|
+
// when view is available
|
|
35417
35767
|
}
|
|
35418
35768
|
onNavigateTo(routeName, params, view) {
|
|
35419
35769
|
var _a, _b;
|
|
@@ -35503,23 +35853,36 @@ class Actions {
|
|
|
35503
35853
|
}
|
|
35504
35854
|
onMoveMessage(json, firstAttach) {
|
|
35505
35855
|
// Handle a move message from Firebase
|
|
35506
|
-
console.log("Move message received: " + JSON.stringify(json));
|
|
35856
|
+
// console.log("Move message received: " + JSON.stringify(json));
|
|
35507
35857
|
this.model.handleMoveMessage(json, firstAttach);
|
|
35508
35858
|
}
|
|
35509
|
-
|
|
35510
|
-
// Handle a user message from Firebase
|
|
35511
|
-
console.log("User message received: " + JSON.stringify(json));
|
|
35512
|
-
this.model.
|
|
35859
|
+
onUserChallengeMessage(json, firstAttach) {
|
|
35860
|
+
// Handle a user challenge message from Firebase
|
|
35861
|
+
// console.log("User challenge message received: " + JSON.stringify(json));
|
|
35862
|
+
this.model.handleUserChallengeMessage(json, firstAttach);
|
|
35863
|
+
}
|
|
35864
|
+
onUserMoveMessage(json, firstAttach) {
|
|
35865
|
+
// Handle a user move message from Firebase
|
|
35866
|
+
// console.log("User move message received: " + JSON.stringify(json));
|
|
35867
|
+
this.model.handleUserMoveMessage(json, firstAttach);
|
|
35513
35868
|
}
|
|
35514
35869
|
onChatMessage(json, firstAttach, view) {
|
|
35870
|
+
var _a;
|
|
35515
35871
|
// Handle an incoming chat message
|
|
35516
|
-
if (firstAttach)
|
|
35517
|
-
console.log("First attach of chat: " + JSON.stringify(json));
|
|
35872
|
+
if (firstAttach) ;
|
|
35518
35873
|
else {
|
|
35519
|
-
console.log("Chat message received: " + JSON.stringify(json));
|
|
35874
|
+
// console.log("Chat message received: " + JSON.stringify(json));
|
|
35520
35875
|
if (this.model.addChatMessage(json.game, json.from_userid, json.msg, json.ts)) {
|
|
35521
35876
|
// A chat message was successfully added
|
|
35522
35877
|
view.notifyChatMessage();
|
|
35878
|
+
// Play audio notification if:
|
|
35879
|
+
// - User has audio enabled
|
|
35880
|
+
// - Message is from opponent (not from current user)
|
|
35881
|
+
const { state } = this.model;
|
|
35882
|
+
const userId = (_a = state.userId) !== null && _a !== void 0 ? _a : "";
|
|
35883
|
+
if (state.audio && json.from_userid !== userId) {
|
|
35884
|
+
playAudio(state, "new-msg");
|
|
35885
|
+
}
|
|
35523
35886
|
}
|
|
35524
35887
|
}
|
|
35525
35888
|
}
|
|
@@ -35620,12 +35983,17 @@ class Actions {
|
|
|
35620
35983
|
}
|
|
35621
35984
|
attachListenerToUser() {
|
|
35622
35985
|
const state = this.model.state;
|
|
35623
|
-
|
|
35624
|
-
|
|
35986
|
+
// console.log(`attachListenerToUser(): userId=${state?.userId}`);
|
|
35987
|
+
if (!state || !state.userId)
|
|
35988
|
+
return;
|
|
35989
|
+
// Listen to challenge and move notifications separately
|
|
35990
|
+
attachFirebaseListener(`user/${state.userId}/challenge`, (json, firstAttach) => this.onUserChallengeMessage(json, firstAttach));
|
|
35991
|
+
attachFirebaseListener(`user/${state.userId}/move`, (json, firstAttach) => this.onUserMoveMessage(json, firstAttach));
|
|
35625
35992
|
}
|
|
35626
35993
|
detachListenerFromUser() {
|
|
35627
35994
|
// Stop listening to Firebase notifications for the current user
|
|
35628
35995
|
const state = this.model.state;
|
|
35996
|
+
// console.log(`detachListenerFromUser(): userId=${state?.userId}`);
|
|
35629
35997
|
if (state && state.userId)
|
|
35630
35998
|
detachFirebaseListener('user/' + state.userId);
|
|
35631
35999
|
}
|
|
@@ -37217,7 +37585,7 @@ async function main(state, container) {
|
|
|
37217
37585
|
const locale = state.locale || "is_IS";
|
|
37218
37586
|
// Log the date being used (helpful for debugging)
|
|
37219
37587
|
if (dateParam) {
|
|
37220
|
-
console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
|
|
37588
|
+
// console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
|
|
37221
37589
|
}
|
|
37222
37590
|
// Mount the Gáta Dagsins UI using an anonymous closure component
|
|
37223
37591
|
m.mount(container, {
|