@mideind/netskrafl-react 1.0.0-beta.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/.eslintignore +8 -0
- package/.eslintrc.json +13 -0
- package/README.md +63 -0
- package/dist/cjs/index.css +6837 -0
- package/dist/cjs/index.js +3046 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.css +6837 -0
- package/dist/esm/index.js +3046 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +63 -0
- package/rollup.config.js +60 -0
- package/src/components/index.ts +2 -0
- package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
- package/src/components/netskrafl/Netskrafl.tsx +135 -0
- package/src/components/netskrafl/Netskrafl.types.ts +7 -0
- package/src/components/netskrafl/index.ts +2 -0
- package/src/css/fonts.css +4 -0
- package/src/css/glyphs.css +224 -0
- package/src/css/skrafl-explo.css +6616 -0
- package/src/fonts/glyphicons-regular.eot +0 -0
- package/src/fonts/glyphicons-regular.ttf +0 -0
- package/src/fonts/glyphicons-regular.woff +0 -0
- package/src/index.ts +2 -0
- package/src/messages/messages.json +1576 -0
- package/src/mithril/actions.ts +319 -0
- package/src/mithril/bag.ts +65 -0
- package/src/mithril/bestdisplay.ts +74 -0
- package/src/mithril/blankdialog.ts +94 -0
- package/src/mithril/board.ts +336 -0
- package/src/mithril/buttons.ts +303 -0
- package/src/mithril/challengedialog.ts +186 -0
- package/src/mithril/channel.ts +162 -0
- package/src/mithril/chat.ts +228 -0
- package/src/mithril/components.ts +496 -0
- package/src/mithril/dragdrop.ts +219 -0
- package/src/mithril/elopage.ts +180 -0
- package/src/mithril/friend.ts +227 -0
- package/src/mithril/game.ts +1378 -0
- package/src/mithril/gameview.ts +111 -0
- package/src/mithril/globalstate.ts +33 -0
- package/src/mithril/i18n.ts +186 -0
- package/src/mithril/localstorage.ts +133 -0
- package/src/mithril/login.ts +122 -0
- package/src/mithril/logo.ts +270 -0
- package/src/mithril/main.ts +737 -0
- package/src/mithril/mithril.ts +29 -0
- package/src/mithril/model.ts +817 -0
- package/src/mithril/movelistitem.ts +226 -0
- package/src/mithril/page.ts +852 -0
- package/src/mithril/playername.ts +91 -0
- package/src/mithril/promodialog.ts +82 -0
- package/src/mithril/recentlist.ts +148 -0
- package/src/mithril/request.ts +52 -0
- package/src/mithril/review.ts +634 -0
- package/src/mithril/rightcolumn.ts +398 -0
- package/src/mithril/searchbutton.ts +118 -0
- package/src/mithril/statsdisplay.ts +109 -0
- package/src/mithril/tabs.ts +169 -0
- package/src/mithril/tile.ts +145 -0
- package/src/mithril/twoletter.ts +76 -0
- package/src/mithril/types.ts +379 -0
- package/src/mithril/userinfodialog.ts +171 -0
- package/src/mithril/util.ts +304 -0
- package/src/mithril/wait.ts +246 -0
- package/src/mithril/wordcheck.ts +102 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +12 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
ChallengeDialog.ts
|
|
4
|
+
|
|
5
|
+
Challenge dialog component
|
|
6
|
+
|
|
7
|
+
Copyright (C) 2024 Miðeind ehf.
|
|
8
|
+
Author: Vilhjalmur Thorsteinsson
|
|
9
|
+
|
|
10
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
11
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
12
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
13
|
+
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { IView, UserListItem } from "./types";
|
|
17
|
+
import { ComponentFunc, m } from "./mithril";
|
|
18
|
+
import { mt, t, ts } from "./i18n";
|
|
19
|
+
import { glyph, nbsp } from "./util";
|
|
20
|
+
import { DialogButton, MultiSelection, OnlinePresence } from "./components";
|
|
21
|
+
|
|
22
|
+
interface IAttributes {
|
|
23
|
+
view: IView;
|
|
24
|
+
item: UserListItem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ChallengeDialog: ComponentFunc<IAttributes> = () => {
|
|
28
|
+
// Show a dialog box for a new challenge being issued
|
|
29
|
+
let manualChallenge = false;
|
|
30
|
+
return {
|
|
31
|
+
view: (vnode) => {
|
|
32
|
+
const { view, item } = vnode.attrs;
|
|
33
|
+
const model = view.model;
|
|
34
|
+
const state = model.state;
|
|
35
|
+
if (!state) return undefined;
|
|
36
|
+
const manual = state.plan !== ""; // If subscriber/friend, allow manual challenges
|
|
37
|
+
const fairPlay = item.fairplay && state.fairPlay; // Both users are fair-play
|
|
38
|
+
return m(".modal-dialog",
|
|
39
|
+
{ id: 'chall-dialog', style: { visibility: 'visible' } },
|
|
40
|
+
m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'chall-form' },
|
|
41
|
+
[
|
|
42
|
+
m(".chall-hdr",
|
|
43
|
+
m("table",
|
|
44
|
+
m("tbody",
|
|
45
|
+
m("tr",
|
|
46
|
+
[
|
|
47
|
+
m("td", m("h1.chall-icon", glyph("hand-right"))),
|
|
48
|
+
m("td.l-border",
|
|
49
|
+
[
|
|
50
|
+
m(OnlinePresence, { id: "chall-online", userId: item.userid }),
|
|
51
|
+
m("h1", item.nick),
|
|
52
|
+
m("h2", item.fullname)
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
),
|
|
60
|
+
m("div", { style: { "text-align": "center" } },
|
|
61
|
+
[
|
|
62
|
+
m(".promo-fullscreen",
|
|
63
|
+
[
|
|
64
|
+
mt("p", [mt("strong", "Ný áskorun"), " - veldu lengd viðureignar:"]),
|
|
65
|
+
m(MultiSelection,
|
|
66
|
+
{ initialSelection: 0, defaultClass: 'chall-time' },
|
|
67
|
+
[
|
|
68
|
+
m("div", { id: 'chall-none', tabindex: 1 },
|
|
69
|
+
t("Viðureign án klukku")
|
|
70
|
+
),
|
|
71
|
+
m("div", { id: 'chall-10', tabindex: 2 },
|
|
72
|
+
[glyph("time"), t("2 x 10 mínútur")]
|
|
73
|
+
),
|
|
74
|
+
m("div", { id: 'chall-15', tabindex: 3 },
|
|
75
|
+
[glyph("time"), t("2 x 15 mínútur")]
|
|
76
|
+
),
|
|
77
|
+
m("div", { id: 'chall-20', tabindex: 4 },
|
|
78
|
+
[glyph("time"), t("2 x 20 mínútur")]
|
|
79
|
+
),
|
|
80
|
+
m("div", { id: 'chall-25', tabindex: 5 },
|
|
81
|
+
[glyph("time"), t("2 x 25 mínútur")]
|
|
82
|
+
),
|
|
83
|
+
state?.runningLocal ? // !!! TODO Debugging aid
|
|
84
|
+
m("div", { id: 'chall-3', tabindex: 6 },
|
|
85
|
+
[glyph("time"), t("2 x 3 mínútur")]
|
|
86
|
+
)
|
|
87
|
+
:
|
|
88
|
+
m("div", { id: 'chall-30', tabindex: 6 },
|
|
89
|
+
[glyph("time"), t("2 x 30 mínútur")]
|
|
90
|
+
)
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
),
|
|
95
|
+
m(".promo-mobile",
|
|
96
|
+
[
|
|
97
|
+
m("p", mt("strong", "Ný áskorun")),
|
|
98
|
+
m(".chall-time.selected",
|
|
99
|
+
{ id: 'extra-none', tabindex: 1 },
|
|
100
|
+
t("Viðureign án klukku")
|
|
101
|
+
)
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
]
|
|
105
|
+
),
|
|
106
|
+
manual ? m("div", { id: "chall-manual" },
|
|
107
|
+
[
|
|
108
|
+
mt("span.caption.wide",
|
|
109
|
+
[
|
|
110
|
+
"Nota ", mt("strong", "handvirka véfengingu"),
|
|
111
|
+
m("br"), "(\"keppnishamur\")"
|
|
112
|
+
]
|
|
113
|
+
),
|
|
114
|
+
m(".toggler[id='manual-toggler'][tabindex='7']",
|
|
115
|
+
{
|
|
116
|
+
onclick: (ev: Event) => {
|
|
117
|
+
manualChallenge = !manualChallenge;
|
|
118
|
+
ev.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[
|
|
122
|
+
m(`.option${manualChallenge ? "" : ".selected"}`,
|
|
123
|
+
m("span", nbsp())
|
|
124
|
+
),
|
|
125
|
+
m(`.option${manualChallenge ? ".selected" : ""}`,
|
|
126
|
+
glyph("lightbulb")
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
]
|
|
131
|
+
) : "",
|
|
132
|
+
fairPlay ? m("div", { id: "chall-fairplay" },
|
|
133
|
+
[
|
|
134
|
+
t("Báðir leikmenn lýsa því yfir að þeir skrafla "),
|
|
135
|
+
m("br"),
|
|
136
|
+
mt("strong", "án stafrænna hjálpartækja"),
|
|
137
|
+
t(" af nokkru tagi"), "."
|
|
138
|
+
]
|
|
139
|
+
) : "",
|
|
140
|
+
m(DialogButton,
|
|
141
|
+
{
|
|
142
|
+
id: "chall-cancel",
|
|
143
|
+
title: ts("Hætta við"),
|
|
144
|
+
tabindex: 8,
|
|
145
|
+
onclick: (ev: Event) => {
|
|
146
|
+
view.popDialog();
|
|
147
|
+
ev.preventDefault();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
glyph("remove")
|
|
151
|
+
),
|
|
152
|
+
m(DialogButton,
|
|
153
|
+
{
|
|
154
|
+
id: "chall-ok",
|
|
155
|
+
title: ts("Skora á"),
|
|
156
|
+
tabindex: 9,
|
|
157
|
+
onclick: (ev: Event) => {
|
|
158
|
+
// Issue a new challenge
|
|
159
|
+
let duration: string | number =
|
|
160
|
+
document.querySelector("div.chall-time.selected")?.id.slice(6) ?? "0";
|
|
161
|
+
if (duration === "none")
|
|
162
|
+
duration = 0;
|
|
163
|
+
else
|
|
164
|
+
duration = parseInt(duration);
|
|
165
|
+
item.chall = true;
|
|
166
|
+
model.modifyChallenge(
|
|
167
|
+
{
|
|
168
|
+
destuser: item.userid,
|
|
169
|
+
action: "issue",
|
|
170
|
+
duration: duration,
|
|
171
|
+
fairplay: fairPlay,
|
|
172
|
+
manual: manualChallenge
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
view.popDialog();
|
|
176
|
+
ev.preventDefault();
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
glyph("ok")
|
|
180
|
+
)
|
|
181
|
+
]
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Channel.ts
|
|
4
|
+
|
|
5
|
+
Utility functions for working with Firebase
|
|
6
|
+
|
|
7
|
+
Copyright (C) 2024 Miðeind ehf.
|
|
8
|
+
Original author: Vilhjálmur Þorsteinsson
|
|
9
|
+
|
|
10
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
11
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
12
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
13
|
+
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { FirebaseApp, FirebaseOptions, initializeApp } from 'firebase/app';
|
|
17
|
+
import { getAnalytics, logEvent as firebaseLogEvent, Analytics } from 'firebase/analytics';
|
|
18
|
+
import { getAuth, signInWithCustomToken, onAuthStateChanged, Auth } from 'firebase/auth';
|
|
19
|
+
import { Database, getDatabase, off, onDisconnect, onValue, push, ref, remove, set } from 'firebase/database';
|
|
20
|
+
|
|
21
|
+
import { GlobalState } from "./globalstate";
|
|
22
|
+
|
|
23
|
+
let app: FirebaseApp;
|
|
24
|
+
let auth: Auth;
|
|
25
|
+
let database: Database;
|
|
26
|
+
let analytics: Analytics;
|
|
27
|
+
|
|
28
|
+
function initFirebase(state: GlobalState) {
|
|
29
|
+
try {
|
|
30
|
+
const projectId = state.projectId;
|
|
31
|
+
const firebaseOptions: FirebaseOptions = {
|
|
32
|
+
projectId,
|
|
33
|
+
apiKey: state.firebaseAPIKey,
|
|
34
|
+
authDomain: `${projectId}.firebaseapp.com`,
|
|
35
|
+
databaseURL: state.databaseURL,
|
|
36
|
+
storageBucket: `${projectId}.firebasestorage.app`,
|
|
37
|
+
messagingSenderId: state.firebaseSenderId,
|
|
38
|
+
appId: state.firebaseAppId,
|
|
39
|
+
measurementId: state.measurementId,
|
|
40
|
+
};
|
|
41
|
+
app = initializeApp(firebaseOptions, "netskrafl");
|
|
42
|
+
if (!app) {
|
|
43
|
+
console.error("Failed to initialize Firebase");
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error("Failed to initialize Firebase", e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function loginFirebase(
|
|
51
|
+
state: GlobalState,
|
|
52
|
+
firebaseToken: string,
|
|
53
|
+
onLoginFunc?: () => void
|
|
54
|
+
) {
|
|
55
|
+
if (!app) initFirebase(state);
|
|
56
|
+
if (!app) return;
|
|
57
|
+
const userId = state.userId;
|
|
58
|
+
const locale = state.locale;
|
|
59
|
+
auth = getAuth(app);
|
|
60
|
+
if (!auth) {
|
|
61
|
+
console.error("Failed to initialize Firebase Auth");
|
|
62
|
+
} else {
|
|
63
|
+
// Register our login function to execute once the user login is done
|
|
64
|
+
onAuthStateChanged(auth,
|
|
65
|
+
(user) => {
|
|
66
|
+
const signedIn = (user !== null);
|
|
67
|
+
if (signedIn) {
|
|
68
|
+
// User is signed in
|
|
69
|
+
onLoginFunc && onLoginFunc();
|
|
70
|
+
// For new users, log an additional signup event
|
|
71
|
+
if (state.newUser) {
|
|
72
|
+
logEvent("sign_up",
|
|
73
|
+
{
|
|
74
|
+
locale: state.locale,
|
|
75
|
+
method: state.loginMethod,
|
|
76
|
+
userid: state.userId
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
// And always log a login event
|
|
81
|
+
logEvent("login",
|
|
82
|
+
{
|
|
83
|
+
locale: state.locale,
|
|
84
|
+
method: state.loginMethod,
|
|
85
|
+
userid: state.userId
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
// No user is signed in
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// Log in to Firebase using the provided custom token
|
|
95
|
+
await signInWithCustomToken(auth, firebaseToken);
|
|
96
|
+
database = getDatabase(app);
|
|
97
|
+
if (!database) {
|
|
98
|
+
console.error("Failed to initialize Firebase Database");
|
|
99
|
+
}
|
|
100
|
+
analytics = getAnalytics(app);
|
|
101
|
+
if (!analytics) {
|
|
102
|
+
console.error("Failed to initialize Firebase Analytics");
|
|
103
|
+
}
|
|
104
|
+
initPresence(state.projectId, userId, locale);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function initPresence(projectId: string, userId: string, locale: string) {
|
|
108
|
+
// Ensure that this user connection is recorded in Firebase
|
|
109
|
+
if (!database) return;
|
|
110
|
+
const connectedRef = ref(database, '.info/connected');
|
|
111
|
+
// Create a unique connection entry for this user
|
|
112
|
+
const connectionPath =
|
|
113
|
+
projectId === "netskrafl"
|
|
114
|
+
? `connection/${userId}`
|
|
115
|
+
: `connection/${locale}/${userId}`;
|
|
116
|
+
const userRef = push(ref(database, connectionPath));
|
|
117
|
+
onValue(connectedRef, (snapshot) => {
|
|
118
|
+
if (snapshot.val()) {
|
|
119
|
+
// We're connected (or reconnected)
|
|
120
|
+
// When this user disconnects, remove this entry
|
|
121
|
+
onDisconnect(userRef).remove();
|
|
122
|
+
// Set presence
|
|
123
|
+
set(userRef, true);
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
// Unset presence
|
|
127
|
+
remove(userRef);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function attachFirebaseListener(
|
|
132
|
+
path: string, func: (json: any, firstAttach: boolean) => void
|
|
133
|
+
) {
|
|
134
|
+
// Attach a message listener to a Firebase path
|
|
135
|
+
if (!database) return;
|
|
136
|
+
let cnt = 0;
|
|
137
|
+
const pathRef = ref(database, path);
|
|
138
|
+
onValue(pathRef, function(snapshot) {
|
|
139
|
+
// Note: we need function() here for proper closure
|
|
140
|
+
// The cnt variable is used to tell the listener whether it's being
|
|
141
|
+
// called upon the first attach or upon a later data change
|
|
142
|
+
cnt++;
|
|
143
|
+
const json = snapshot.val();
|
|
144
|
+
// console.log("received on path", path, ":", json);
|
|
145
|
+
if (json) {
|
|
146
|
+
func(json, cnt == 1);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function detachFirebaseListener(path: string) {
|
|
152
|
+
// Detach a message listener from a Firebase path
|
|
153
|
+
if (!database) return;
|
|
154
|
+
const pathRef = ref(database, path);
|
|
155
|
+
off(pathRef);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function logEvent(ev: string, params: any) {
|
|
159
|
+
// Log a Firebase analytics event
|
|
160
|
+
if (!analytics) return;
|
|
161
|
+
firebaseLogEvent(analytics, ev, params);
|
|
162
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Chat.ts
|
|
4
|
+
|
|
5
|
+
Chat component
|
|
6
|
+
|
|
7
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
8
|
+
Author: Vilhjalmur Thorsteinsson
|
|
9
|
+
|
|
10
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
11
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
12
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
13
|
+
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { IView } from "./types";
|
|
17
|
+
import { ts } from "./i18n";
|
|
18
|
+
import { ComponentFunc, m, VnodeChildren, VnodeDOM } from "./mithril";
|
|
19
|
+
import { escapeHtml, getInput, glyph, setInput } from "./util";
|
|
20
|
+
import { DialogButton } from "./components";
|
|
21
|
+
import { serverUrl } from "./request";
|
|
22
|
+
|
|
23
|
+
// Max number of chat messages per game
|
|
24
|
+
const MAX_CHAT_MESSAGES = 250;
|
|
25
|
+
|
|
26
|
+
interface IAttributes {
|
|
27
|
+
view: IView;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const EMOTICONS = [
|
|
31
|
+
{ icon: ":-)", image: "/static/icontexto_emoticons_03.png" },
|
|
32
|
+
{ icon: ":-D", image: "/static/icontexto_emoticons_02.png" },
|
|
33
|
+
{ icon: ";-)", image: "/static/icontexto_emoticons_04.png" },
|
|
34
|
+
{ icon: ":-(", image: "/static/icontexto_emoticons_12.png" },
|
|
35
|
+
{ icon: ":-o", image: "/static/icontexto_emoticons_10.png" },
|
|
36
|
+
{ icon: ":-O", image: "/static/icontexto_emoticons_10.png" },
|
|
37
|
+
{ icon: ":-p", image: "/static/icontexto_emoticons_14.png" },
|
|
38
|
+
{ icon: ":-P", image: "/static/icontexto_emoticons_14.png" },
|
|
39
|
+
{ icon: "B-)", image: "/static/icontexto_emoticons_16.png" },
|
|
40
|
+
{ icon: ":)", image: "/static/icontexto_emoticons_03.png" },
|
|
41
|
+
{ icon: ":D", image: "/static/icontexto_emoticons_02.png" },
|
|
42
|
+
{ icon: ";)", image: "/static/icontexto_emoticons_04.png" },
|
|
43
|
+
{ icon: ":(", image: "/static/icontexto_emoticons_12.png" },
|
|
44
|
+
{ icon: ":o", image: "/static/icontexto_emoticons_10.png" },
|
|
45
|
+
{ icon: ":O", image: "/static/icontexto_emoticons_10.png" },
|
|
46
|
+
{ icon: ":p", image: "/static/icontexto_emoticons_14.png" },
|
|
47
|
+
{ icon: ":P", image: "/static/icontexto_emoticons_14.png" },
|
|
48
|
+
{ icon: "(y)", image: "/static/thumb-up.png" }
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export const Chat: ComponentFunc<IAttributes> = (initialVnode) => {
|
|
52
|
+
// The chat tab
|
|
53
|
+
|
|
54
|
+
const { view } = initialVnode.attrs;
|
|
55
|
+
const model = view.model;
|
|
56
|
+
const game = model.game!;
|
|
57
|
+
const state = model.state!;
|
|
58
|
+
|
|
59
|
+
function decodeTimestamp(ts: string) {
|
|
60
|
+
// Parse and split an ISO timestamp string, formatted as YYYY-MM-DD HH:MM:SS
|
|
61
|
+
return {
|
|
62
|
+
year: parseInt(ts.slice(0, 4)),
|
|
63
|
+
month: parseInt(ts.slice(5, 7)),
|
|
64
|
+
day: parseInt(ts.slice(8, 10)),
|
|
65
|
+
hour: parseInt(ts.slice(11, 13)),
|
|
66
|
+
minute: parseInt(ts.slice(14, 16)),
|
|
67
|
+
second: parseInt(ts.slice(17, 19))
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dateFromTimestamp(ts: string) {
|
|
72
|
+
// Create a JavaScript millisecond-based representation of an ISO timestamp
|
|
73
|
+
var dcTs = decodeTimestamp(ts);
|
|
74
|
+
return Date.UTC(dcTs.year, dcTs.month - 1, dcTs.day,
|
|
75
|
+
dcTs.hour, dcTs.minute, dcTs.second);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function timeDiff(dtFrom: number, dtTo: number) {
|
|
79
|
+
// Return the difference between two JavaScript time points, in seconds
|
|
80
|
+
return Math.round((dtTo - dtFrom) / 1000.0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let dtLastMsg: number | null = null;
|
|
84
|
+
|
|
85
|
+
function makeTimestamp(ts: string, key: number): VnodeChildren {
|
|
86
|
+
// Decode the ISO format timestamp we got from the server
|
|
87
|
+
let dtTs = dateFromTimestamp(ts);
|
|
88
|
+
let result: VnodeChildren = null;
|
|
89
|
+
if (dtLastMsg === null || timeDiff(dtLastMsg, dtTs) >= 5 * 60) {
|
|
90
|
+
// If 5 minutes or longer interval between messages,
|
|
91
|
+
// insert a time
|
|
92
|
+
const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours expressed in milliseconds
|
|
93
|
+
const dtNow = new Date().getTime();
|
|
94
|
+
let dtToday = dtNow - dtNow % ONE_DAY; // Start of today (00:00 UTC)
|
|
95
|
+
let dtYesterday = dtToday - ONE_DAY; // Start of yesterday
|
|
96
|
+
let strTs: string;
|
|
97
|
+
if (dtTs < dtYesterday) {
|
|
98
|
+
// Older than today or yesterday: Show full timestamp YYYY-MM-DD HH:MM
|
|
99
|
+
strTs = ts.slice(0, -3);
|
|
100
|
+
} else if (dtTs < dtToday) {
|
|
101
|
+
// Yesterday
|
|
102
|
+
strTs = "Í gær " + ts.slice(11, 16);
|
|
103
|
+
} else {
|
|
104
|
+
// Today
|
|
105
|
+
strTs = ts.slice(11, 16);
|
|
106
|
+
}
|
|
107
|
+
result = m(".chat-ts", { key: key }, strTs);
|
|
108
|
+
}
|
|
109
|
+
dtLastMsg = dtTs;
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const player = game ? game.player : null;
|
|
114
|
+
|
|
115
|
+
function replaceEmoticons(str: string): string {
|
|
116
|
+
// Replace all emoticon shortcuts in the string str with a corresponding image URL
|
|
117
|
+
for (const emoticon of EMOTICONS)
|
|
118
|
+
if (str.indexOf(emoticon.icon) >= 0) {
|
|
119
|
+
// The string contains the emoticon: prepare to replace all occurrences
|
|
120
|
+
const imgUrl = serverUrl(emoticon.image);
|
|
121
|
+
const img = `<img src='${imgUrl}' height='32' width='32'>`;
|
|
122
|
+
// Re the following trick, see https://stackoverflow.com/questions/1144783/
|
|
123
|
+
// replacing-all-occurrences-of-a-string-in-javascript
|
|
124
|
+
str = str.split(emoticon.icon).join(img);
|
|
125
|
+
}
|
|
126
|
+
return str;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function chatMessages(): VnodeChildren {
|
|
130
|
+
let r: VnodeChildren = [];
|
|
131
|
+
if (game?.chatLoading || !game?.messages)
|
|
132
|
+
return r;
|
|
133
|
+
var key = 0;
|
|
134
|
+
const userId = model.state?.userId ?? "";
|
|
135
|
+
for (const msg of game.messages) {
|
|
136
|
+
let p = player ?? 0;
|
|
137
|
+
if (msg.from_userid != userId)
|
|
138
|
+
p = 1 - p;
|
|
139
|
+
const mTs = makeTimestamp(msg.ts, key);
|
|
140
|
+
if (mTs !== null) {
|
|
141
|
+
r.push(mTs);
|
|
142
|
+
key++;
|
|
143
|
+
}
|
|
144
|
+
let escMsg = escapeHtml(msg.msg);
|
|
145
|
+
escMsg = replaceEmoticons(escMsg);
|
|
146
|
+
r.push(m(".chat-msg" +
|
|
147
|
+
(p === 0 ? ".left" : ".right") +
|
|
148
|
+
(p === player ? ".local" : ".remote"),
|
|
149
|
+
{ key: key++ },
|
|
150
|
+
m.trust(escMsg))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return r;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function scrollChatToBottom(): void {
|
|
157
|
+
// Scroll the last chat message into view
|
|
158
|
+
const chatlist = document.querySelectorAll("#chat-area .chat-msg");
|
|
159
|
+
if (!chatlist.length)
|
|
160
|
+
return;
|
|
161
|
+
const target: HTMLElement = chatlist[chatlist.length - 1] as HTMLElement;
|
|
162
|
+
(target.parentNode as HTMLElement).scrollTop = target.offsetTop;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function focus(vnode: VnodeDOM) {
|
|
166
|
+
// Put the focus on the DOM object associated with the vnode
|
|
167
|
+
if (!view.isDialogShown()) {
|
|
168
|
+
// Don't hijack the focus from a dialog overlay
|
|
169
|
+
(vnode.dom as HTMLElement).focus();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sendMessage(): void {
|
|
174
|
+
let msg = getInput("msg").trim();
|
|
175
|
+
if (game && msg.length > 0) {
|
|
176
|
+
game.sendMessage(msg);
|
|
177
|
+
setInput("msg", "");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const numMessages = game?.messages ? game.messages.length : 0;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
view: () => m(".chat",
|
|
185
|
+
{
|
|
186
|
+
style: "z-index: 6" // Appear on top of board on mobile
|
|
187
|
+
// key: uuid
|
|
188
|
+
},
|
|
189
|
+
[
|
|
190
|
+
m(".chat-area" + (game?.showClock() ? ".with-clock" : ""),
|
|
191
|
+
{
|
|
192
|
+
id: 'chat-area',
|
|
193
|
+
// Make sure that we see the bottom-most chat message
|
|
194
|
+
oncreate: scrollChatToBottom,
|
|
195
|
+
onupdate: scrollChatToBottom
|
|
196
|
+
},
|
|
197
|
+
chatMessages()
|
|
198
|
+
),
|
|
199
|
+
m(".chat-input",
|
|
200
|
+
[
|
|
201
|
+
m("input.chat-txt",
|
|
202
|
+
{
|
|
203
|
+
type: "text",
|
|
204
|
+
id: "msg",
|
|
205
|
+
name: "msg",
|
|
206
|
+
maxlength: 254,
|
|
207
|
+
disabled: (numMessages >= MAX_CHAT_MESSAGES),
|
|
208
|
+
oncreate: (vnode) => { focus(vnode); },
|
|
209
|
+
onupdate: (vnode) => { focus(vnode); },
|
|
210
|
+
onkeypress: (ev: KeyboardEvent) => {
|
|
211
|
+
if (ev.key == "Enter") { sendMessage(); ev.preventDefault(); }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
),
|
|
215
|
+
m(DialogButton,
|
|
216
|
+
{
|
|
217
|
+
id: "chat-send",
|
|
218
|
+
title: ts("Senda"),
|
|
219
|
+
onclick: (ev: Event) => { sendMessage(); ev.preventDefault(); }
|
|
220
|
+
},
|
|
221
|
+
glyph("chat"),
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
)
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
};
|