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