[mod] clean model-view-control
Christian Fraß

Christian Fraß commited on 2021-11-20 15:02:57
Zeige 11 geänderte Dateien mit 826 Einfügungen und 543 Löschungen.

... ...
@@ -11,7 +11,7 @@
11 11
 	"irc": {
12 12
 		"server": "irc.libera.chat",
13 13
 		"predefined_channel": "#linux",
14
-		"predefined_nickname_prefix": "dude_"
14
+		"predefined_nickname_prefix": "dude"
15 15
 	}
16 16
 }
17 17
 
... ...
@@ -32,7 +32,6 @@ A simple IRC client, realized as web application
32 32
 # Plans and ToDos
33 33
 
34 34
 - use websockets instead of polling
35
-- support multiple channels and private messages
36 35
 - support commands (e.g. `/nick new_name`)
37 36
 - get correct user list (seems like it does not update when a user leaves the room)
38 37
 
... ...
@@ -0,0 +1,98 @@
1
+namespace ns_control
2
+{
3
+	
4
+	/**
5
+	 * ugly hack to prevent multiple listener addition
6
+	 */
7
+	function register_listener
8
+	(
9
+		dom_element: HTMLElement,
10
+		eventname: string,
11
+		handler: (event)=>void
12
+	): void
13
+	{
14
+		const classname: string = `has_listener_for_${eventname}`;
15
+		if (! dom_element.classList.contains(classname))
16
+		{
17
+			dom_element.addEventListener(eventname, handler);
18
+			dom_element.classList.add(classname);
19
+		}
20
+		else
21
+		{
22
+			// do nothing
23
+		}
24
+	}
25
+	
26
+	
27
+	/**
28
+	 * sets up the control
29
+	 */
30
+	export function setup
31
+	(
32
+		conf: type_conf,
33
+		model: type_model
34
+	): void
35
+	{
36
+		// connect
37
+		{
38
+			register_listener
39
+			(
40
+				document.querySelector<HTMLFormElement>("#connect > form"),
41
+				"submit",
42
+				(e) =>
43
+				{
44
+					e.preventDefault();
45
+					const nickname: string = document.querySelector<HTMLInputElement>("#nickname").value;
46
+					const channel_names: Array<string> = document.querySelector<HTMLInputElement>("#channel").value.split(",");
47
+					ns_model.connect(conf, model, nickname, channel_names);
48
+				}
49
+			);
50
+		}
51
+		// disconnect
52
+		{
53
+			register_listener
54
+			(
55
+				document.querySelector<HTMLButtonElement>("#disconnect"),
56
+				"click",
57
+				(e) =>
58
+				{
59
+					ns_model.disconnect(conf, model);
60
+				}
61
+			);
62
+		}
63
+		// send
64
+		{
65
+			register_listener
66
+			(
67
+				document.querySelector("#main > form"),
68
+				"submit",
69
+				(e) =>
70
+				{
71
+					e.preventDefault();
72
+					const content: string = document.querySelector<HTMLInputElement>("#content").value;
73
+					ns_model.send(conf, model, content);
74
+				}
75
+			);
76
+		}
77
+		// switch spot
78
+		{
79
+			document.querySelectorAll(".spot").forEach
80
+			(
81
+				(dom_spot) =>
82
+				{
83
+					register_listener
84
+					(
85
+						(dom_spot as HTMLElement),
86
+						"click",
87
+						(e) =>
88
+						{
89
+							const spot: type_spot = JSON.parse(dom_spot.getAttribute("rel"));
90
+							ns_model.set_active(model, spot);
91
+						}
92
+					);
93
+				}
94
+			);
95
+		}
96
+	}
97
+	
98
+}
... ...
@@ -0,0 +1,65 @@
1
+/**
2
+ * gets the current UNIX timestamp
3
+ */
4
+function get_timestamp
5
+(
6
+): int
7
+{
8
+	return Math.floor(Date.now()/1000);
9
+}
10
+
11
+
12
+/**
13
+ * computes a floating point number in the interval [0,1[ out of a string
14
+ */
15
+function hash_string_to_unit
16
+(
17
+	x: string
18
+): float
19
+{
20
+	return (x.split("").reduce((x, y) => ((x + y.charCodeAt(0)) % 32), 0) / 32);
21
+}
22
+
23
+
24
+/**
25
+ * encodes a username as a CSS color
26
+ */
27
+function get_usercolor
28
+(
29
+	name: string
30
+): string
31
+{
32
+	const hue: float = hash_string_to_unit(name);
33
+	return `hsl(${(hue*360).toFixed(2)},50%,75%)`;
34
+}
35
+
36
+
37
+/**
38
+ * calls an API action of the backend
39
+ */
40
+async function backend_call
41
+(
42
+	conf: type_conf,
43
+	connection_id: (null | string),
44
+	action: string, data: any
45
+): Promise<any>
46
+{
47
+	const response: any = await fetch
48
+	(
49
+		`${conf.backend.scheme}://${conf.backend.host}:${conf.backend.port.toFixed(0)}/${conf.backend.path}`,
50
+		{
51
+			"method": "POST",
52
+			"body": JSON.stringify({"id": connection_id, "action": action, "data": data}),
53
+		}
54
+	);
55
+	if (response.ok)
56
+	{
57
+		return response.json();
58
+	}
59
+	else
60
+	{
61
+		console.error(response.text());
62
+		return Promise.reject<any>(new Error("backend call failed"));
63
+	}
64
+}
65
+
... ...
@@ -5,7 +5,6 @@
5 5
 		<meta name="viewport" content="width=device-width, initial-scale=1.0">
6 6
 		<link rel="stylesheet" type="text/css" href="style.css"/>
7 7
 		<script type="text/javascript" src="logic.js"></script>
8
-		<script type="text/javascript">init();</script>
9 8
 		<title>wirc</title>
10 9
 	</head>
11 10
 	<body class="offline">
... ...
@@ -1,539 +0,0 @@
1
-type int = number;
2
-type float = number;
3
-
4
-
5
-var _conf: any = null;
6
-
7
-
8
-function get_timestamp(): int
9
-{
10
-	return Math.floor(Date.now()/1000);
11
-}
12
-
13
-
14
-function hash_string_to_unit(x: string): float
15
-{
16
-	return (x.split("").reduce((x, y) => ((x + y.charCodeAt(0)) % 32), 0) / 32);
17
-}
18
-
19
-
20
-function get_usercolor(name: string): string
21
-{
22
-	const hue: float = hash_string_to_unit(name);
23
-	return `hsl(${(hue*360).toFixed(2)},50%,75%)`;
24
-}
25
-
26
-
27
-async function backend_call(connection_id: (null | string), action: string, data: any): Promise<any>
28
-{
29
-	const response = await fetch
30
-	(
31
-		`${_conf["backend"]["scheme"]}://${_conf["backend"]["host"]}:${_conf["backend"]["port"].toFixed(0)}/${_conf["backend"]["path"]}`,
32
-		{
33
-			"method": "POST",
34
-			"body": JSON.stringify({"id": connection_id, "action": action, "data": data}),
35
-		}
36
-	);
37
-	if (response.ok)
38
-	{
39
-		return response.json();
40
-	}
41
-	else
42
-	{
43
-		console.error(response.text());
44
-		return Promise.reject<any>(new Error("backend call failed"));
45
-	}
46
-}
47
-
48
-enum enum_state
49
-{
50
-	offline = "offline",
51
-	connecting = "connecting",
52
-	online = "online",
53
-}
54
-
55
-
56
-type type_event =
57
-{
58
-	timestamp: int;
59
-	kind: string;
60
-	data: any;
61
-};
62
-
63
-
64
-type type_spot =
65
-{
66
-	kind: string;
67
-	name: string;
68
-};
69
-
70
-
71
-type type_entry =
72
-{
73
-	timestamp: int;
74
-	sender: string;
75
-	content: string;
76
-};
77
-
78
-
79
-type type_user =
80
-{
81
-	name: string;
82
-	role: string;
83
-};
84
-
85
-
86
-type type_channel =
87
-{
88
-	users: Array<type_user>;
89
-	entries: Array<type_entry>;
90
-};
91
-
92
-
93
-type type_query =
94
-{
95
-	entries: Array<type_entry>;
96
-};
97
-
98
-
99
-type type_model =
100
-{
101
-	state: enum_state;
102
-	connection_id: (null | string);
103
-	nickname: (null | string);
104
-	channels: Record<string, type_channel>;
105
-	queries: Record<string, type_query>;
106
-	active: (null | type_spot);
107
-};
108
-
109
-
110
-
111
-function model_set_state(model: type_model, state: enum_state): void
112
-{
113
-	model.state = state;
114
-	view_update_state(model);
115
-}
116
-
117
-
118
-function model_set_active(model: type_model, spot: type_spot): void
119
-{
120
-	model.active = spot;
121
-	view_update_spots(model);
122
-	view_update_entries(model);
123
-	view_update_users(model);
124
-}
125
-
126
-
127
-function model_process_events(model: type_model, events: Array<type_event>): void
128
-{
129
-	let shall_update_spots: boolean = false;
130
-	let shall_update_entries: boolean = false;
131
-	let shall_update_users: boolean = false;
132
-	for (const event of events)
133
-	{
134
-		switch (event.kind)
135
-		{
136
-			default:
137
-			{
138
-				console.warn("unhandled event kind: " + event.kind);
139
-				break;
140
-			}
141
-			case "userlist":
142
-			{
143
-				model.channels[event.data["channel"]].users = event.data["users"];
144
-				shall_update_users = true;
145
-				break;
146
-			}
147
-			case "message_channel":
148
-			{
149
-				model.channels[event.data["channel"]].entries.push
150
-				({
151
-					"timestamp": event.timestamp,
152
-					"sender": event.data["sender"],
153
-					"content": event.data["content"],
154
-				});
155
-				shall_update_entries = true;
156
-				break;
157
-			}
158
-			case "message_query":
159
-			{
160
-				if (! model.queries.hasOwnProperty(event.data["user_name"]))
161
-				{
162
-					model.queries[event.data["user_name"]] = {"entries": []};
163
-					shall_update_spots = true;
164
-				}
165
-				else
166
-				{
167
-					// do nothing
168
-				}
169
-				model.queries[event.data["user_name"]].entries.push
170
-				({
171
-					"timestamp": event.timestamp,
172
-					"sender": event.data["sender"],
173
-					"content": event.data["content"],
174
-				});
175
-				shall_update_entries = true;
176
-				break;
177
-			}
178
-		}
179
-	}
180
-	
181
-	// update view
182
-	{
183
-		if (shall_update_spots) view_update_spots(model);
184
-		if (shall_update_entries) view_update_entries(model);
185
-		if (shall_update_users) view_update_users(model);
186
-	}
187
-}
188
-
189
-
190
-
191
-function view_update_state(model: type_model): void
192
-{
193
-	document.querySelector("body").setAttribute("class", model.state);
194
-}
195
-
196
-
197
-function view_update_spots(model: type_model): void
198
-{
199
-	let dom_spots: HTMLUListElement = document.querySelector("#spots");
200
-	const spots: Array<type_spot> = (
201
-		[]
202
-		.concat(Object.keys(model.channels).map((name) => ({"kind": "channel", "name": name})))
203
-		.concat(Object.keys(model.queries).map((name) => ({"kind": "query", "name": name})))
204
-	);
205
-	dom_spots.textContent = "";
206
-	for (const spot of spots)
207
-	{
208
-		let dom_spot: HTMLLIElement = document.createElement("li");
209
-		dom_spot.classList.add("spot");
210
-		{
211
-			let dom_kind: HTMLSpanElement = document.createElement("span");
212
-			dom_kind.classList.add("spot_kind");
213
-			dom_kind.textContent = spot.kind;
214
-			dom_spot.appendChild(dom_kind);
215
-		}
216
-		{
217
-			let dom_name: HTMLSpanElement = document.createElement("span");
218
-			dom_name.classList.add("spot_sender");
219
-			dom_name.textContent = spot.name;
220
-			dom_spot.appendChild(dom_name);
221
-		}
222
-		dom_spot.classList.toggle("spot_active", ((spot.kind === model.active.kind) && (spot.name === model.active.name)));
223
-		dom_spot.addEventListener
224
-		(
225
-			"click",
226
-			(e) =>
227
-			{
228
-				model_set_active(model, spot);
229
-			}
230
-		);
231
-		dom_spots.appendChild(dom_spot);
232
-	}
233
-}
234
-
235
-
236
-function view_update_entries(model: type_model): void
237
-{
238
-	let dom_entries: HTMLUListElement = document.querySelector("#entries");
239
-	let entries: Array<type_entry>;
240
-	switch (model.active.kind)
241
-	{
242
-		case "channel":
243
-		{
244
-			entries = model.channels[model.active.name].entries;
245
-			break;
246
-		}
247
-		case "query":
248
-		{
249
-			entries = model.queries[model.active.name].entries;
250
-			break;
251
-		}
252
-	}
253
-	dom_entries.textContent = "";
254
-	for (const entry of entries)
255
-	{
256
-		let dom_entry: HTMLLIElement = document.createElement("li");
257
-		dom_entry.classList.add("entry");
258
-		{
259
-			let dom_time: HTMLSpanElement = document.createElement("span");
260
-			dom_time.classList.add("entry_time");
261
-			dom_time.textContent = (new Date(entry.timestamp*1000)).toISOString().slice(11, 19);
262
-			dom_entry.appendChild(dom_time);
263
-		}
264
-		{
265
-			let dom_sender: HTMLSpanElement = document.createElement("span");
266
-			dom_sender.classList.add("entry_sender");
267
-			dom_sender.style.color = get_usercolor(entry.sender);
268
-			dom_sender.textContent = entry.sender;
269
-			dom_entry.appendChild(dom_sender);
270
-		}
271
-		{
272
-			let dom_content: HTMLSpanElement = document.createElement("span");
273
-			dom_content.classList.add("entry_content");
274
-			dom_content.textContent = entry.content;
275
-			dom_entry.appendChild(dom_content);
276
-		}
277
-		dom_entries.appendChild(dom_entry);
278
-	}
279
-	dom_entries.scrollTo(0, dom_entries["scrollTopMax"]);
280
-}
281
-
282
-
283
-function view_update_users(model: type_model): void
284
-{
285
-	let dom_users: HTMLUListElement = document.querySelector("#users");
286
-	dom_users.textContent = "";
287
-	let users: Array<type_user>;
288
-	switch (model.active.kind)
289
-	{
290
-		default:
291
-		{
292
-			console.warn("unhandled kind: " + model.active.kind);
293
-			users = [];
294
-			break;
295
-		}
296
-		case "channel":
297
-		{
298
-			users = model.channels[model.active.name].users;
299
-			break;
300
-		}
301
-		case "query":
302
-		{
303
-			users = [{"name": model.nickname, "role": ""}, {"name": model.active.name, "role": ""}];
304
-			break;
305
-		}
306
-	}
307
-	const users_sorted: Array<type_user> = users.sort
308
-	(
309
-		(x, y) =>
310
-		(
311
-			(x.role >= y.role)
312
-			? -1
313
-			: (
314
-				(x.role === y.role)
315
-				? ((x.name < y.name) ? -1 : +1)
316
-				: +1
317
-			)
318
-		)
319
-	);
320
-	for (const user of users_sorted)
321
-	{
322
-		let dom_user: HTMLLIElement = document.createElement("li");
323
-		dom_user.classList.add("user");
324
-		{
325
-			let dom_role: HTMLSpanElement = document.createElement("span");
326
-			dom_role.textContent = user.role;
327
-			dom_user.appendChild(dom_role);
328
-		}
329
-		{
330
-			let dom_name: HTMLSpanElement = document.createElement("span");
331
-			dom_name.textContent = user.name;
332
-			dom_name.style.color = get_usercolor(user.name);
333
-			dom_user.appendChild(dom_name);
334
-		}
335
-		dom_users.appendChild(dom_user);
336
-	}
337
-}
338
-
339
-
340
-function view_setup(model: type_model): void
341
-{
342
-	document.querySelector<HTMLInputElement>("#channel").value = _conf["irc"]["predefined_channel"];
343
-	document.querySelector<HTMLInputElement>("#nickname").value = (_conf["irc"]["predefined_nickname_prefix"] + (Math.random()*100).toFixed(0));
344
-	setInterval
345
-	(
346
-		async () =>
347
-		{
348
-			switch (model.state)
349
-			{
350
-				default:
351
-				{
352
-					throw (new Error("invalid state: " + model.state));
353
-					break;
354
-				}
355
-				case enum_state.offline:
356
-				{
357
-					// do nothing
358
-					break;
359
-				}
360
-				case enum_state.connecting:
361
-				{
362
-					const ready: boolean = await backend_call(model.connection_id, "check", null);
363
-					if (ready)
364
-					{
365
-						model_set_state(model, enum_state.online);
366
-					}
367
-					else
368
-					{
369
-						// do nothing
370
-					}
371
-					break;
372
-				}
373
-				case enum_state.online:
374
-				{
375
-					const events: Array<type_event> = await backend_call(model.connection_id, "fetch", null);
376
-					model_process_events(model, events);
377
-					break;
378
-				}
379
-			}
380
-		},
381
-		_conf["settings"]["poll_interval_in_milliseconds"]
382
-	);
383
-	model_set_state(model, enum_state.offline);
384
-}
385
-
386
-
387
-function control_setup(model: type_model): void
388
-{
389
-	document.querySelector("#connect > form").addEventListener
390
-	(
391
-		"submit",
392
-		async (event) =>
393
-		{
394
-			event.preventDefault();
395
-			
396
-			model_set_state(model, enum_state.connecting);
397
-			
398
-			let dom_nickname: HTMLInputElement = document.querySelector<HTMLInputElement>("#nickname");
399
-			let dom_channel: HTMLInputElement = document.querySelector<HTMLInputElement>("#channel");
400
-			const nickname: string = dom_nickname.value;
401
-			const channel_names: Array<string> = dom_channel.value.split(",");
402
-			
403
-			const connection_id: string = await backend_call
404
-			(
405
-				model.connection_id,
406
-				"connect",
407
-				{
408
-					"server": _conf["irc"]["server"],
409
-					"channels": channel_names,
410
-					"nickname": nickname,
411
-				}
412
-			);
413
-			model.connection_id = connection_id;
414
-			model.nickname = nickname;
415
-			for (const channel_name of channel_names)
416
-			{
417
-				model.channels[channel_name] =
418
-				{
419
-					"users": [],
420
-					"entries": [],
421
-				};
422
-			}
423
-			// TODO: can crash
424
-			model_set_active(model, {"kind": "channel", "name": channel_names[0]});
425
-		}
426
-	);
427
-	document.querySelector("#disconnect").addEventListener
428
-	(
429
-		"click",
430
-		async (event) =>
431
-		{
432
-			await backend_call
433
-			(
434
-				model.connection_id,
435
-				"disconnect",
436
-				null
437
-			);
438
-			model_set_state(model, enum_state.offline);
439
-			model.connection_id = null;
440
-		}
441
-	);
442
-	document.querySelector("#main > form").addEventListener
443
-	(
444
-		"submit",
445
-		async (e) =>
446
-		{
447
-			event.preventDefault();
448
-			
449
-			let dom_content: HTMLInputElement = document.querySelector<HTMLInputElement>("#content");
450
-			const content: string = dom_content.value;
451
-			dom_content.value = "";
452
-			dom_content.focus();
453
-			switch (model.active.kind)
454
-			{
455
-				case "channel":
456
-				{
457
-					const event: type_event =
458
-					{
459
-						"timestamp": get_timestamp(),
460
-						"kind": "message_channel",
461
-						"data":
462
-						{
463
-							"channel": model.active.name,
464
-							"sender": model.nickname,
465
-							"content": content,
466
-						}
467
-					};
468
-					model_process_events(model, [event]);
469
-					view_update_entries(model);
470
-					backend_call
471
-					(
472
-						model.connection_id,
473
-						"send_channel",
474
-						{
475
-							"channel": model.active.name,
476
-							"content": content,
477
-						}
478
-					);
479
-					break;
480
-				}
481
-				case "query":
482
-				{
483
-					const event: type_event =
484
-					{
485
-						"timestamp": get_timestamp(),
486
-						"kind": "message_query",
487
-						"data":
488
-						{
489
-							"user_name": model.active.name,
490
-							"sender": model.nickname,
491
-							"content": content,
492
-						}
493
-					};
494
-					model_process_events(model, [event]);
495
-					backend_call
496
-					(
497
-						model.connection_id,
498
-						"send_query",
499
-						{
500
-							"receiver": model.active.name,
501
-							"content": content,
502
-						}
503
-					);
504
-					break;
505
-				}
506
-			}
507
-		}
508
-	);
509
-}
510
-
511
-
512
-var _model;
513
-async function main(): Promise<void>
514
-{
515
-	_conf = await fetch("conf.json").then(x => x.json());
516
-	const model: type_model =
517
-	{
518
-		"state": enum_state.offline,
519
-		"connection_id": null,
520
-		"nickname": null,
521
-		"channels": {},
522
-		"queries": {},
523
-		"active": null,
524
-	};
525
-_model = model;
526
-	view_setup(model);
527
-	control_setup(model);
528
-}
529
-
530
-
531
-function init(): void
532
-{
533
-	document.addEventListener
534
-	(
535
-		"DOMContentLoaded",
536
-		(event) => {main();}
537
-	);
538
-}
539
-
... ...
@@ -0,0 +1,28 @@
1
+var _model;
2
+/**
3
+ * initializes the system
4
+ */
5
+async function main
6
+(
7
+): Promise<void>
8
+{
9
+	const conf: type_conf = await fetch("conf.json").then<type_conf>(x => x.json());
10
+	const model: type_model =
11
+	{
12
+		"state": enum_state.offline,
13
+		"connection_id": null,
14
+		"nickname": null,
15
+		"channels": {},
16
+		"queries": {},
17
+		"active": null,
18
+		"listeners": {},
19
+	};
20
+_model = model;
21
+	ns_model.setup(conf, model);
22
+	ns_view.setup(conf, model);
23
+	ns_control.setup(conf, model);
24
+}
25
+
26
+
27
+document.addEventListener("DOMContentLoaded", () => {main();});
28
+
... ...
@@ -0,0 +1,331 @@
1
+namespace ns_model
2
+{
3
+	
4
+	/**
5
+	 * adds a listener for a certain incident
6
+	 */
7
+	export function listen
8
+	(
9
+		model: type_model,
10
+		incident: string,
11
+		handler: (details?: any)=>void
12
+	): void
13
+	{
14
+		if (! model.listeners.hasOwnProperty(incident))
15
+		{
16
+			model.listeners[incident] = [];
17
+		}
18
+		else
19
+		{
20
+			// do nothing
21
+		}
22
+		model.listeners[incident].push(handler);
23
+	}
24
+
25
+
26
+	/**
27
+	 * sends a notification to all listeners for a certain incident
28
+	 */
29
+	function notify
30
+	(
31
+		model: type_model,
32
+		incident: string,
33
+		details: any = null
34
+	): void
35
+	{
36
+		if (model.listeners.hasOwnProperty(incident))
37
+		{
38
+			for (const handler of model.listeners[incident])
39
+			{
40
+				handler(details);
41
+			}
42
+		}
43
+	}
44
+
45
+
46
+	/**
47
+	 * sets the state
48
+	 */
49
+	export function set_state
50
+	(
51
+		model: type_model,
52
+		state: enum_state
53
+	): void
54
+	{
55
+		model.state = state;
56
+		notify(model, "state_changed");
57
+	}
58
+
59
+
60
+	/**
61
+	 * sets the active spot (channel or query)
62
+	 */
63
+	export function set_active
64
+	(
65
+		model: type_model,
66
+		spot: type_spot
67
+	): void
68
+	{
69
+		model.active = spot;
70
+		notify(model, "spots_changed");
71
+		notify(model, "entries_changed");
72
+		notify(model, "users_changed");
73
+	}
74
+
75
+
76
+	/**
77
+	 * updates the model according to a list of events
78
+	 */
79
+	function process_events
80
+	(
81
+		model: type_model,
82
+		events: Array<type_event>
83
+	): void
84
+	{
85
+		let shall_update_spots: boolean = false;
86
+		let shall_update_entries: boolean = false;
87
+		let shall_update_users: boolean = false;
88
+		
89
+		for (const event of events)
90
+		{
91
+			switch (event.kind)
92
+			{
93
+				default:
94
+				{
95
+					console.warn("unhandled event kind: " + event.kind);
96
+					break;
97
+				}
98
+				case "userlist":
99
+				{
100
+					model.channels[event.data["channel"]].users = event.data["users"];
101
+					shall_update_users = true;
102
+					break;
103
+				}
104
+				case "message_channel":
105
+				{
106
+					model.channels[event.data["channel"]].entries.push
107
+					({
108
+						"timestamp": event.timestamp,
109
+						"sender": event.data["sender"],
110
+						"content": event.data["content"],
111
+					});
112
+					shall_update_entries = true;
113
+					break;
114
+				}
115
+				case "message_query":
116
+				{
117
+					if (! model.queries.hasOwnProperty(event.data["user_name"]))
118
+					{
119
+						model.queries[event.data["user_name"]] = {"entries": []};
120
+						shall_update_spots = true;
121
+					}
122
+					else
123
+					{
124
+						// do nothing
125
+					}
126
+					model.queries[event.data["user_name"]].entries.push
127
+					({
128
+						"timestamp": event.timestamp,
129
+						"sender": event.data["sender"],
130
+						"content": event.data["content"],
131
+					});
132
+					shall_update_entries = true;
133
+					break;
134
+				}
135
+			}
136
+		}
137
+		
138
+		if (shall_update_spots) notify(model, "spots_changed");
139
+		if (shall_update_entries) notify(model, "entries_changed");
140
+		if (shall_update_users) notify(model, "users_changed");
141
+	}
142
+
143
+
144
+	/**
145
+	 * establishes the connection
146
+	 */
147
+	export async function connect
148
+	(
149
+		conf: type_conf,
150
+		model: type_model,
151
+		nickname: string,
152
+		channel_names: Array<string>
153
+	): Promise<void>
154
+	{
155
+		set_state(model, enum_state.connecting);
156
+		const connection_id: string = await backend_call
157
+		(
158
+			conf,
159
+			model.connection_id,
160
+			"connect",
161
+			{
162
+				"server": conf.irc.server,
163
+				"channels": channel_names,
164
+				"nickname": nickname,
165
+			}
166
+		);
167
+		model.connection_id = connection_id;
168
+		model.nickname = nickname;
169
+		for (const channel_name of channel_names)
170
+		{
171
+			model.channels[channel_name] =
172
+			{
173
+				"users": [],
174
+				"entries": [],
175
+			};
176
+		}
177
+		if (channel_names.length > 0)
178
+		{
179
+			set_active(model, {"kind": "channel", "name": channel_names[0]});
180
+		}
181
+		return Promise.resolve<void>(undefined);
182
+	}
183
+
184
+
185
+	/**
186
+	 * closes the connection
187
+	 */
188
+	export async function disconnect
189
+	(
190
+		conf: type_conf,
191
+		model: type_model
192
+	): Promise<void>
193
+	{
194
+		await backend_call
195
+		(
196
+			conf,
197
+			model.connection_id,
198
+			"disconnect",
199
+			null
200
+		);
201
+		set_state(model, enum_state.offline);
202
+		model.connection_id = null;
203
+		return Promise.resolve<void>(undefined);
204
+	}
205
+
206
+
207
+	/**
208
+	 * adds a client side message
209
+	 */
210
+	export function send
211
+	(
212
+		conf: type_conf,
213
+		model: type_model,
214
+		content: string
215
+	): void
216
+	{
217
+		switch (model.active.kind)
218
+		{
219
+			case "channel":
220
+			{
221
+				backend_call
222
+				(
223
+					conf,
224
+					model.connection_id,
225
+					"send_channel",
226
+					{
227
+						"channel": model.active.name,
228
+						"content": content,
229
+					}
230
+				);
231
+				const event: type_event =
232
+				{
233
+					"timestamp": get_timestamp(),
234
+					"kind": "message_channel",
235
+					"data":
236
+					{
237
+						"channel": model.active.name,
238
+						"sender": model.nickname,
239
+						"content": content,
240
+					}
241
+				};
242
+				process_events(model, [event]);
243
+				notify(model, "entries_changed");
244
+				notify(model, "message_sent");
245
+				break;
246
+			}
247
+			case "query":
248
+			{
249
+				backend_call
250
+				(
251
+					conf,
252
+					model.connection_id,
253
+					"send_query",
254
+					{
255
+						"receiver": model.active.name,
256
+						"content": content,
257
+					}
258
+				);
259
+				const event: type_event =
260
+				{
261
+					"timestamp": get_timestamp(),
262
+					"kind": "message_query",
263
+					"data":
264
+					{
265
+						"user_name": model.active.name,
266
+						"sender": model.nickname,
267
+						"content": content,
268
+					}
269
+				};
270
+				process_events(model, [event]);
271
+				notify(model, "entries_changed");
272
+				notify(model, "message_sent");
273
+				break;
274
+			}
275
+		}
276
+	}
277
+	
278
+	
279
+	/**
280
+	 * sets up the model
281
+	 */
282
+	export function setup
283
+	(
284
+		conf: type_conf,
285
+		model: type_model
286
+	): void
287
+	{
288
+		setInterval
289
+		(
290
+			async () =>
291
+			{
292
+				switch (model.state)
293
+				{
294
+					default:
295
+					{
296
+						throw (new Error(`invalid state '${model.state}'`));
297
+						break;
298
+					}
299
+					case enum_state.offline:
300
+					{
301
+						// do nothing
302
+						break;
303
+					}
304
+					case enum_state.connecting:
305
+					{
306
+						const ready: boolean = await backend_call(conf, model.connection_id, "check", null);
307
+						if (ready)
308
+						{
309
+							set_state(model, enum_state.online);
310
+						}
311
+						else
312
+						{
313
+							// do nothing
314
+						}
315
+						break;
316
+					}
317
+					case enum_state.online:
318
+					{
319
+						const events: Array<type_event> = await backend_call(conf, model.connection_id, "fetch", null);
320
+						process_events(model, events);
321
+						break;
322
+					}
323
+				}
324
+			},
325
+			conf.settings.poll_interval_in_milliseconds
326
+		);
327
+		set_state(model, enum_state.offline);
328
+	}
329
+	
330
+}
331
+
... ...
@@ -0,0 +1,91 @@
1
+type int = number;
2
+
3
+
4
+type float = number;
5
+
6
+
7
+type type_conf =
8
+{
9
+	backend:
10
+	{
11
+		scheme: string;
12
+		host: string;
13
+		port: int;
14
+		path: string;
15
+	};
16
+	settings:
17
+	{
18
+		poll_interval_in_milliseconds: 2000;
19
+	};
20
+	irc:
21
+	{
22
+		server: string;
23
+		predefined_channel: string;
24
+		predefined_nickname_prefix: string;
25
+	}
26
+};
27
+
28
+
29
+enum enum_state
30
+{
31
+	offline = "offline",
32
+	connecting = "connecting",
33
+	online = "online",
34
+}
35
+
36
+
37
+type type_event =
38
+{
39
+	timestamp: int;
40
+	kind: string;
41
+	data: any;
42
+};
43
+
44
+
45
+type type_spot =
46
+{
47
+	kind: string;
48
+	name: string;
49
+};
50
+
51
+
52
+type type_entry =
53
+{
54
+	timestamp: int;
55
+	sender: string;
56
+	content: string;
57
+};
58
+
59
+
60
+type type_user =
61
+{
62
+	name: string;
63
+	role: string;
64
+};
65
+
66
+
67
+type type_channel =
68
+{
69
+	users: Array<type_user>;
70
+	entries: Array<type_entry>;
71
+};
72
+
73
+
74
+type type_query =
75
+{
76
+	entries: Array<type_entry>;
77
+};
78
+
79
+
80
+type type_model =
81
+{
82
+	state: enum_state;
83
+	connection_id: (null | string);
84
+	nickname: (null | string);
85
+	channels: Record<string, type_channel>;
86
+	queries: Record<string, type_query>;
87
+	active: (null | type_spot);
88
+	listeners: Record<string, Array<(details?: any)=>void>>;
89
+};
90
+
91
+
... ...
@@ -0,0 +1,205 @@
1
+namespace ns_view
2
+{
3
+
4
+	/**
5
+	 * updates the state (switches between login, connecting and regular "page")
6
+	 */
7
+	function update_state
8
+	(
9
+		model: type_model
10
+	): void
11
+	{
12
+		document.querySelector("body").setAttribute("class", model.state);
13
+	}
14
+
15
+
16
+	/**
17
+	 * updates the spots (channels and queries)
18
+	 */
19
+	function update_spots
20
+	(
21
+		conf: type_conf,
22
+		model: type_model
23
+	): void
24
+	{
25
+		let dom_spots: HTMLUListElement = document.querySelector("#spots");
26
+		const spots: Array<type_spot> = (
27
+			[]
28
+			.concat(Object.keys(model.channels).map((name) => ({"kind": "channel", "name": name})))
29
+			.concat(Object.keys(model.queries).map((name) => ({"kind": "query", "name": name})))
30
+		);
31
+		dom_spots.textContent = "";
32
+		for (const spot of spots)
33
+		{
34
+			let dom_spot: HTMLLIElement = document.createElement("li");
35
+			dom_spot.classList.add("spot");
36
+			{
37
+				let dom_kind: HTMLSpanElement = document.createElement("span");
38
+				dom_kind.classList.add("spot_kind");
39
+				dom_kind.textContent = spot.kind;
40
+				dom_spot.appendChild(dom_kind);
41
+			}
42
+			{
43
+				let dom_name: HTMLSpanElement = document.createElement("span");
44
+				dom_name.classList.add("spot_sender");
45
+				dom_name.textContent = spot.name;
46
+				dom_spot.appendChild(dom_name);
47
+			}
48
+			dom_spot.classList.toggle("spot_active", ((spot.kind === model.active.kind) && (spot.name === model.active.name)));
49
+			dom_spot.setAttribute("rel", JSON.stringify(spot));
50
+			dom_spots.appendChild(dom_spot);
51
+		}
52
+		// meeh…
53
+		ns_control.setup(conf, model);
54
+	}
55
+
56
+
57
+	/**
58
+	 * updates the chat entries
59
+	 */
60
+	function update_entries
61
+	(
62
+		model: type_model
63
+	): void
64
+	{
65
+		let dom_entries: HTMLUListElement = document.querySelector("#entries");
66
+		let entries: Array<type_entry>;
67
+		switch (model.active.kind)
68
+		{
69
+			case "channel":
70
+			{
71
+				entries = model.channels[model.active.name].entries;
72
+				break;
73
+			}
74
+			case "query":
75
+			{
76
+				entries = model.queries[model.active.name].entries;
77
+				break;
78
+			}
79
+		}
80
+		dom_entries.textContent = "";
81
+		for (const entry of entries)
82
+		{
83
+			let dom_entry: HTMLLIElement = document.createElement("li");
84
+			dom_entry.classList.add("entry");
85
+			{
86
+				let dom_time: HTMLSpanElement = document.createElement("span");
87
+				dom_time.classList.add("entry_time");
88
+				dom_time.textContent = (new Date(entry.timestamp*1000)).toISOString().slice(11, 19);
89
+				dom_entry.appendChild(dom_time);
90
+			}
91
+			{
92
+				let dom_sender: HTMLSpanElement = document.createElement("span");
93
+				dom_sender.classList.add("entry_sender");
94
+				dom_sender.style.color = get_usercolor(entry.sender);
95
+				dom_sender.textContent = entry.sender;
96
+				dom_entry.appendChild(dom_sender);
97
+			}
98
+			{
99
+				let dom_content: HTMLSpanElement = document.createElement("span");
100
+				dom_content.classList.add("entry_content");
101
+				dom_content.textContent = entry.content;
102
+				dom_entry.appendChild(dom_content);
103
+			}
104
+			dom_entries.appendChild(dom_entry);
105
+		}
106
+		dom_entries.scrollTo(0, dom_entries["scrollTopMax"]);
107
+	}
108
+
109
+
110
+	/**
111
+	 * updates the user list
112
+	 */
113
+	function update_users
114
+	(
115
+		model: type_model
116
+	): void
117
+	{
118
+		let dom_users: HTMLUListElement = document.querySelector("#users");
119
+		dom_users.textContent = "";
120
+		let users: Array<type_user>;
121
+		switch (model.active.kind)
122
+		{
123
+			default:
124
+			{
125
+				console.warn("unhandled kind: " + model.active.kind);
126
+				users = [];
127
+				break;
128
+			}
129
+			case "channel":
130
+			{
131
+				users = model.channels[model.active.name].users;
132
+				break;
133
+			}
134
+			case "query":
135
+			{
136
+				users = [{"name": model.nickname, "role": ""}, {"name": model.active.name, "role": ""}];
137
+				break;
138
+			}
139
+		}
140
+		const users_sorted: Array<type_user> = users.sort
141
+		(
142
+			(x, y) =>
143
+			(
144
+				(x.role >= y.role)
145
+				? -1
146
+				: (
147
+					(x.role === y.role)
148
+					? ((x.name < y.name) ? -1 : +1)
149
+					: +1
150
+				)
151
+			)
152
+		);
153
+		for (const user of users_sorted)
154
+		{
155
+			let dom_user: HTMLLIElement = document.createElement("li");
156
+			dom_user.classList.add("user");
157
+			{
158
+				let dom_role: HTMLSpanElement = document.createElement("span");
159
+				dom_role.textContent = user.role;
160
+				dom_user.appendChild(dom_role);
161
+			}
162
+			{
163
+				let dom_name: HTMLSpanElement = document.createElement("span");
164
+				dom_name.textContent = user.name;
165
+				dom_name.style.color = get_usercolor(user.name);
166
+				dom_user.appendChild(dom_name);
167
+			}
168
+			dom_users.appendChild(dom_user);
169
+		}
170
+	}
171
+
172
+
173
+	/**
174
+	 * clears the content and focus on the message content input
175
+	 */
176
+	function clear_content
177
+	(
178
+	): void
179
+	{
180
+		let dom_content: HTMLInputElement = document.querySelector<HTMLInputElement>("#content");
181
+		dom_content.value = "";
182
+		dom_content.focus();
183
+	}
184
+
185
+
186
+	/**
187
+	 * sets up the view
188
+	 */
189
+	export function setup
190
+	(
191
+		conf: type_conf,
192
+		model: type_model
193
+	): void
194
+	{
195
+		document.querySelector<HTMLInputElement>("#channel").value = conf.irc.predefined_channel;
196
+		document.querySelector<HTMLInputElement>("#nickname").value = (conf.irc.predefined_nickname_prefix + (Math.random()*100).toFixed(0));
197
+		
198
+		ns_model.listen(model, "state_changed", () => {update_state(model);});
199
+		ns_model.listen(model, "spots_changed", () => {update_spots(conf, model);});
200
+		ns_model.listen(model, "entries_changed", () => {update_entries(model);});
201
+		ns_model.listen(model, "users_changed", () => {update_users(model);});
202
+		ns_model.listen(model, "message_sent", () => {clear_content();});
203
+	}
204
+	
205
+}
... ...
@@ -29,7 +29,13 @@ ${dir_build}/index.html: ${dir_source}/index.html
29 29
 logic: ${dir_build}/logic.js
30 30
 .PHONY: logic
31 31
 
32
-${dir_build}/logic.js: ${dir_source}/logic.ts
32
+${dir_build}/logic.js: \
33
+	${dir_source}/types.ts \
34
+	${dir_source}/helpers.ts \
35
+	${dir_source}/model.ts \
36
+	${dir_source}/view.ts \
37
+	${dir_source}/control.ts \
38
+	${dir_source}/main.ts
33 39
 	@ ${cmd_log} "logic …"
34 40
 	@ ${cmd_mkdir} $(dir $@)
35 41
 	@ ${cmd_tsc} $^ --outFile $@
36 42