-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy path13-voting.md.erb
600 lines (479 loc) · 21.9 KB
/
13-voting.md.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
---
title: Votazioni
slug: votazioni
date: 0013/01/01
number: 13
contents: Implementare un sistema di votazione dei post.|Ordinare i post in base al voto su una pagina dei post "migliori".|Imparare a scrivere un helper Spacebars generico.|Approfondimento sulla sicurezza dei dati in Meteor.|Alcune interessanti considerazioni riguardanti le prestazioni di MongoDB.
paragraphs: 49
---
Adesso che il sito sta guadagnando in popolarità, trovare i link migliori sta cominciando a diventare difficile. È necessario un qualche sistema ordinamento per i post.
Si potrebbe implementare un complesso sistema di ordinamento basato su karma, decadimento del punteggio a tempo, e svariati altri parametri (la maggior parte dei quali implementati in [Telescope](http://telesc.pe/), il fratello maggiore di Microscope). Nella nostra applicazione invece si punta alla semplicità, per cui i post saranno ordinati in base al numero di voti ricevuti.
Per cominciare, verrà fornita agli utenti la possibilità di votare un post.
### Modello dei Dati
Per determinare se visualizzare agli utenti il pulsante di upvote o meno, per ciascun post verrà salvata la lista degli utenti che lo hanno votato, che consente anche di evitare che un utente voti più di una volta.
<% note do %>
### Riservatezza dei Dati & Pubblicazioni
Le liste dei votanti saranno disponibili a tutti gli utenti, il che renderà i dati automaticamente e pubblicamente consultabili tramite la console del browser.
Questo è il tipico problema di riservatezza dei dati causato dal modo in cui le collezioni funzionano. Ad esempio, si vuole consentire agli utenti la ricerca degli utenti che hanno votato i loro post? Nel caso in questione rendere tali informazioni pubbliche non ha reali conseguenze, ma è importante almeno avere consapevolezza del problema.
Da notare che se si volesse limitare l'accesso ad alcune di queste informazioni, ci si dovrebbe assicurare che lato client non venga consentito di smanettare con le opzioni dei campi `fields` della pubblicazione, rimuovendo la proprietà lato server, oppure evitando di passare per intero le opzioni dal client al server.
<% end %>
Inoltre, il totale dei voti per ciascun post sarà denormalizzato, al fine di ottenere in maniera semplice tale contatore. Verranno quindi aggiunti due attributi ai post, `upvoters` e `votes`. Per cominciare, i suddetti campi verranno aggiunti ai dati di esempio:
~~~js
// Fixture data
if (Posts.find().count() === 0) {
var now = new Date().getTime();
// create two users
var tomId = Meteor.users.insert({
profile: { name: 'Tom Coleman' }
});
var tom = Meteor.users.findOne(tomId);
var sachaId = Meteor.users.insert({
profile: { name: 'Sacha Greif' }
});
var sacha = Meteor.users.findOne(sachaId);
var telescopeId = Posts.insert({
title: 'Introducing Telescope',
userId: sacha._id,
author: sacha.profile.name,
url: 'http://sachagreif.com/introducing-telescope/',
submitted: now - 7 * 3600 * 1000,
commentsCount: 2,
upvoters: [], votes: 0
});
Comments.insert({
postId: telescopeId,
userId: tom._id,
author: tom.profile.name,
submitted: now - 5 * 3600 * 1000,
body: 'Interesting project Sacha, can I get involved?'
});
Comments.insert({
postId: telescopeId,
userId: sacha._id,
author: sacha.profile.name,
submitted: now - 3 * 3600 * 1000,
body: 'You sure can Tom!'
});
Posts.insert({
title: 'Meteor',
userId: tom._id,
author: tom.profile.name,
url: 'http://meteor.com',
submitted: now - 10 * 3600 * 1000,
commentsCount: 0,
upvoters: [], votes: 0
});
Posts.insert({
title: 'The Meteor Book',
userId: tom._id,
author: tom.profile.name,
url: 'http://themeteorbook.com',
submitted: now - 12 * 3600 * 1000,
commentsCount: 0,
upvoters: [], votes: 0
});
for (var i = 0; i < 10; i++) {
Posts.insert({
title: 'Test post #' + i,
author: sacha.profile.name,
userId: sacha._id,
url: 'http://google.com/?q=test-' + i,
submitted: now - i * 3600 * 1000,
commentsCount: 0,
upvoters: [], votes: 0
});
}
}
~~~
<%= caption "server/fixtures.js" %>
<%= highlight "22, 48, 58, 69" %>
Al solito, fermare l'applicazione, eseguire `meteor reset`, riavviare l'app, quindi creare un nuovo account. È opportuno assicurarsi che i due campi vengano inizializzati al momento della creazione dei post:
~~~js
//...
// check that there are no previous posts with the same link
if (postAttributes.url && postWithSameLink) {
throw new Meteor.Error(302,
'This link has already been posted',
postWithSameLink._id);
}
// pick out the whitelisted keys
var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
userId: user._id,
author: user.username,
submitted: new Date().getTime(),
commentsCount: 0,
upvoters: [],
votes: 0
});
var postId = Posts.insert(post);
return postId;
//...
~~~
<%= caption "collections/posts.js" %>
<%= highlight "16~17" %>
### Implementazione dei template per le votazioni
Per prima cosa, verrà aggiunto un pulsante al template del post:
~~~html
<template name="postItem">
<div class="post">
<a href="#" class="upvote btn">⬆</a>
<div class="post-content">
<h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
<p>
{{votes}} Votes,
submitted by {{author}},
<a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
{{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
</div>
<a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
</div>
</template>
~~~
<%= caption "client/views/posts/post_item.html" %>
<%= highlight "3,7" %>
<%= screenshot "13-1", "Il pulsante per votare" %>
Verrà quindi chiamato un metodo lato server per eseguire l'upvote quando l'utente clicca sul pulsante:
~~~js
//...
Template.postItem.events({
'click .upvote': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
~~~
<%= caption "client/views/posts/post_item.js" %>
<%= highlight "3~8" %>
Infine, verrà aggiunto un metodo lato server in `collections/posts.js` che eseguirà la registrazione del voto:
~~~js
Meteor.methods({
post: function(postAttributes) {
//...
},
upvote: function(postId) {
var user = Meteor.user();
// ensure the user is logged in
if (!user)
throw new Meteor.Error(401, "You need to login to upvote");
var post = Posts.findOne(postId);
if (!post)
throw new Meteor.Error(422, 'Post not found');
if (_.include(post.upvoters, user._id))
throw new Meteor.Error(422, 'Already upvoted this post');
Posts.update(post._id, {
$addToSet: {upvoters: user._id},
$inc: {votes: 1}
});
}
});
~~~
<%= caption "collections/posts.js" %>
<%= highlight "6~23" %>
<%= commit "13-1", "Added basic upvoting algorithm." %>
Questo methodo è sufficientemente autoesplicativo. Si eseguono alcuni controlli preventivi per assicurarsi che l'utente sia loggato e che il post esista. Quindi si controlla che l'utente non abbia già espresso il suo voto per il post, quindi si incrementa il contatore dei voti e si aggiunge l'utente alla lista dei votanti.
Questa ultima operazione si rivela interessante, in quanto vengono utilizzato due operatori speciali di Mongo. Ce ne sono molti altri da imparare, ma questi due sono estremamente utili: `$addToSet` aggiunge un elemento ad un array se non già incluso, mentre `$inc` semplicemente incrementa il valore di un campo di tipo intero.
### Ritocchi all'Interfaccia Utente
Se l'utente non è loggato, oppure ha già votato, non deve essere in grado di votare nuovamente. Per evidenziare questo caso sull'interfaccia, verrà utilizzata una funzione di supporto che aggiunge, ove necessario, la classe CSS `disabled` al pulsante per la votazione.
~~~html
<template name="postItem">
<div class="post">
<a href="#" class="upvote btn {{upvotedClass}}">⬆</a>
<div class="post-content">
//...
</div>
</template>
~~~
<%= caption "client/views/posts/post_item.html" %>
<%= highlight "3" %>
~~~js
Template.postItem.helpers({
ownPost: function() {
//...
},
domain: function() {
//...
},
upvotedClass: function() {
var userId = Meteor.userId();
if (userId && !_.include(this.upvoters, userId)) {
return 'btn-primary upvotable';
} else {
return 'disabled';
}
}
});
Template.postItem.events({
'click .upvotable': function(e) {
e.preventDefault();
Meteor.call('upvote', this._id);
}
});
~~~
<%= caption "client/views/posts/post_item.js" %>
<%= highlight "8~15, 19" %>
La classe viene cambiata da `.upvote` a `.upvotable`, quindi è opportuno non tralasciare di modificare il gestore dell'evento click.
<%= screenshot "13-2", "Disabilitazione del pulsante per la votazione." %>
<%= commit "13-2", "Grey out upvote link when not logged in / already voted." %>
Ora, si può notare che i post con un singolo voto sono etichettati come "1 vote__s__", vediamo quindi come formattare il plurale (in Inglese) in modo appropriato. Mettere in forma plurale può essere un processo complicato, ma per adesso verrà affrontato in modo semplicistico. Si implementerà una funzione Spacebars generica, che può essere riutilizzata altrove.
~~~js
UI.registerHelper('pluralize', function(n, thing) {
// fairly stupid pluralizer
if (n === 1) {
return '1 ' + thing;
} else {
return n + ' ' + thing + 's';
}
});
~~~
<%= caption "client/helpers/Spacebars.js" %>
Le funzioni che sono state create sono legate ai relativi manager e template. Ma utilizzando `Spacebars.registerHelper` viene creata una funzione di supporto _globale_ che può essere utilizzata in qualsiasi template:
~~~html
<template name="postItem">
//...
<p>
{{pluralize votes "Vote"}},
submitted by {{author}},
<a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
{{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>
//...
</template>
~~~
<%= caption "client/views/posts/post_item.html" %>
<%= highlight "4, 6" %>
<%= screenshot "13-3", "Perfezionamento della forma plurale" %>
<%= commit "13-3", "Added pluralize helper to format text better." %>
Adesso si dovrebbe vedere `1 vote`.
### Miglioramento dell'Algoritmo di Votazione
Il codice che implementa la votazione dei post sembra sufficientemente buono, ma può essere ulteriormente migliorato. Nel metodo `upvote` vengono eseguite 2 chiamate a Mongo: la prima per ottenere il post, l'altra per modificarlo.
Ci sono due problemi in questa scelta implementativa. Per prima cosa, è inefficiente accedere al database per 2 volte. Ma ben più importante, introduce una corsa critica. L'algoritmo implementato è il seguente:
1. Legge il post dal database
2. Verifica se l'utente ha votato
3. In caso negativo, esegue il voto
Cosa succede se lo stesso utente vota nuovamente lo stesso post durante i passi 1 e 3? Questa versione consente all'utente di votare 2 volte per lo stesso post. Fortunatamente, Mongo fornisce un metodo più efficace combinando i passi 1-3 in un singolo comando:
~~~js
Meteor.methods({
post: function(postAttributes) {
//...
},
upvote: function(postId) {
var user = Meteor.user();
// ensure the user is logged in
if (!user)
throw new Meteor.Error(401, "You need to login to upvote");
Posts.update({
_id: postId,
upvoters: {$ne: user._id}
}, {
$addToSet: {upvoters: user._id},
$inc: {votes: 1}
});
}
});
~~~
<%= caption "collections/posts.js" %>
<%= highlight "12~15" %>
<%= commit "13-4", "Better upvoting algorithm." %>
Il codice si traduce in: "trova tutti i post con questo `id` per cui l'utente non ha ancora votato, e aggiornali in questo modo". Se l'utente _non_ ha ancora votato, il post con quell'`id` sarà ovviamente trovato. D'altro canto, se l'utente ha *già* votato, allora la ricerca non produrrà alcun risultato, e di conseguenza non succederà niente.
Il solo problema adesso adesso è che non si può informare l'utente riguardo la votazione già eseguita per il post (poiché è stata eliminata la chiamata al database che eseguiva la verifica). Ad ogni modo, l'utente dovrebbe esserne a conoscenza dato che il pulsante "upvote" nell'interfaccia utente è disabilitato.
<% note do %>
### Compensazione della Latenza
Supponiamo che si provi a barare spostando uno dei propri post in cima alla lista, modificando manualmente il numero dei voti:
~~~js
> Posts.update(postId, {$set: {votes: 10000}});
~~~
<%= caption "Console del browser" %>
(Dove `postId` è l'id di uno dei propri post).
Questo tentativo di ingannare il sistema verrebbe intercettato dal callback `deny()` (in `collections/posts.js`) ed immediatamente annullato.
Ma esaminando attentamente, è possibile che si veda in azione la compensazione di latenza. Può durare un instante, ma il post sarà temporaneamente spostato in cima alla lista prima di essere rispedito alla sua posizione originaria.
Cosa è successo? Nella collezione locale `Posts`, l'`update` è stato eseguito senza problemi. Ciò accade istantaneamente, per cui il post salta in cima alla lista. Al contempo, sul server l'`update` viene negato. Quindi qualche istante dopo (misurato in millisecondi se Meteor è in esecuzione sul proprio computer), il server restituisce l'errore, istruendo la collezione locale a ripristinare la modifica.
Risultato finale: durante l'attesa di una risposta da parte del server, l'interfaccia utente non può far altro che considerare attendibile la collezione locale. Non appena il server nega la modifica, l'interfaccia utente si adatta di conseguenza.
<% end %>
### Classifica dei post
Ora che ciascun post ha un punteggio dipendente dal numero dei voti, si può visualizzare una lista dei migliori post. Per far ciò, si vedrà come sia possibile gestire due sottoscrizioni separate operanti sulla collezione dei post, e rendere maggiormente generico il template `postsList`.
Per cominciare, sono necessarie _due_ sottoscrizioni, una per ciascun ordinamento. Il trucco sta nel fatto che entrambe saranno sottoscritte alla _stessa_ pubblicazione `posts`, solo con parametri differenti!
Verranno inoltre create due nuove route chiamate `newPosts` e `bestPosts`, accedibili tramite le URL rispettivamente `/new` e `/best` (ovviamente insieme a `/new/5` e `/best/5` per la paginazione).
Per realizzare quanto detto, verrà _esteso_ `PostsListController` in due distinti controller `NewPostsListController` e `BestPostsListController`. Questo consentirà il riuso delle medesime opzioni di routing sia per la route `home` che per `newPosts`, a partire da un singolo `NewPostsListController` da cui ereditare. Tutti ciò dimostra quanto Iron Router possa essere flessibile.
~~~js
PostsListController = RouteController.extend({
template: 'postsList',
increment: 5,
limit: function() {
return parseInt(this.params.postsLimit) || this.increment;
},
findOptions: function() {
return {sort: this.sort, limit: this.limit()};
},
waitOn: function() {
return Meteor.subscribe('posts', this.findOptions());
},
posts: function() {
return Posts.find({}, this.findOptions());
},
data: function() {
var hasMore = this.posts().count() === this.limit();
return {
posts: this.posts(),
nextPath: hasMore ? this.nextPath() : null
};
}
});
NewPostsListController = PostsListController.extend({
sort: {submitted: -1, _id: -1},
nextPath: function() {
return Router.routes.newPosts.path({postsLimit: this.limit() + this.increment})
}
});
BestPostsListController = PostsListController.extend({
sort: {votes: -1, submitted: -1, _id: -1},
nextPath: function() {
return Router.routes.bestPosts.path({postsLimit: this.limit() + this.increment})
}
});
Router.map(function() {
this.route('home', {
path: '/',
controller: NewPostsListController
});
this.route('newPosts', {
path: '/new/:postsLimit?',
controller: NewPostsListController
});
this.route('bestPosts', {
path: '/best/:postsLimit?',
controller: BestPostsListController
});
// ..
});
~~~
<%= caption "lib/router.js" %>
<%= highlight "8,20,25~37,40~53" %>
Da notare che avendo adesso più di una route, la logica dietro `nextPath` è stata spostata da `PostsListController` a `NewPostsListController` `BestPostsListController`, poiché il percorso sarà diverso in entrambi i casi.
Inoltre, quando si ordina per `votes` si ha un parametro di ordinamento aggiuntivo per timestamp di submit, per assicurare che l'ordinamento sia corretto.
Dopo aver introdotto i nuovi controller, possiamo ora rimuovere senza problemi la *route* `postsList` precedentemente definita. Cancelliamo qunidi il seguente codice:
```
this.route('postsList', {
path: '/:postsLimit?',
controller: PostsListController
})
```
<%= caption "lib/router.js" %>
Aggiungeremo anche i collegamenti nell'intestazione:
~~~html
<template name="header">
<header class="navbar">
<div class="navbar-inner">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<a class="brand" href="{{pathFor 'home'}}">Microscope</a>
<div class="nav-collapse collapse">
<ul class="nav">
<li>
<a href="{{pathFor 'newPosts'}}">New</a>
</li>
<li>
<a href="{{pathFor 'bestPosts'}}">Best</a>
</li>
{{#if currentUser}}
<li>
<a href="{{pathFor 'postSubmit'}}">Submit Post</a>
</li>
<li class="dropdown">
{{> notifications}}
</li>
{{/if}}
</ul>
<ul class="nav pull-right">
<li>{{> loginButtons}}</li>
</ul>
</div>
</div>
</header>
</template>
~~~
<%= caption "client/views/includes/header.html" %>
<%= highlight "9, 12~17" %>
Dobbiamo anche aggiornare il gestore dell'evento di cancellazione dei post:
~~~html
'click .delete': function(e) {
e.preventDefault();
if (confirm("Delete this post?")) {
var currentPostId = this._id;
Posts.remove(currentPostId);
Router.go('home');
}
}
~~~
<%= caption "client/views/posts_edit.js" %>
<%= highlight "7" %>
Fatto ciò, si ottiene la lista dei migliori post:
<%= screenshot "13-4", "Ordinamento per punteggio" %>
<%= commit "13-5", "Added routes for post lists, and pages to display them." %>
### Migliorare l'Header
Avendo due pagine che elencano i post, non è chiaro quale delle due liste è correntemente visualizzata. È necessario quindi modificare l'header per rendere la cosa esplicita. Viene creato un gestore `header.js` e una funzione di supporto che usa il percorso corrente e una o più route per aggiungere una classe attiva alle voci di navigazione.
La ragione per cui si vogliono supportare più route è che sia `home` che `newPosts` (a cui corrispondono le URL rispettivamente `/` e `/new`) puntano allo stesso template. Quindi `activeRouteClass` deve essere sufficientemente intelligente da rendere il tag `<li>`attivo in entrambi i casi.
~~~html
<template name="header">
<header class="navbar">
<div class="navbar-inner">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<a class="brand" href="{{pathFor 'home'}}">Microscope</a>
<div class="nav-collapse collapse">
<ul class="nav">
<li class="{{activeRouteClass 'home' 'newPosts'}}">
<a href="{{pathFor 'newPosts'}}">New</a>
</li>
<li class="{{activeRouteClass 'bestPosts'}}">
<a href="{{pathFor 'bestPosts'}}">Best</a>
</li>
{{#if currentUser}}
<li class="{{activeRouteClass 'postSubmit'}}">
<a href="{{pathFor 'postSubmit'}}">Submit Post</a>
</li>
<li class="dropdown">
{{> notifications}}
</li>
{{/if}}
</ul>
<ul class="nav pull-right">
<li>{{> loginButtons}}</li>
</ul>
</div>
</div>
</header>
</template>
~~~
<%= caption "client/views/includes/header.html" %>
<%= highlight "9,12,15,19" %>
~~~js
Template.header.helpers({
activeRouteClass: function(/* route names */) {
var args = Array.prototype.slice.call(arguments, 0);
args.pop();
var active = _.any(args, function(name) {
return Router.current() && Router.current().route.name === name
});
return active && 'active';
}
});
~~~
<%= caption "client/views/includes/header.js" %>
<%= screenshot "13-5", "Evidenziazione della pagina attiva" %>
<% note do %>
### Parametri dell'Helper
Non è stato usato finora questo specifico pattern, ma come ogni tag Spacebars, i tag del template helper possono ricevere parametri.
E se da un lato è possibile passare parametri con nome alla funzione, è anche possibile passare un numero non specificato di parametri anonimi, acceduti tramite l'oggetto `arguments` dall'interno della funzione.
In quest'ultimo caso, può risultare conveniente convertire l'oggetto `arguments` in un array JavaScript, e quindi utilizzare il metodo `pop()` al fine di liberarsi dell'hash aggiunto da Spacebars.
<% end %>
Per ciascuna voce di navigazione, l'helper `activeRouteClass` accetta una lista di nomi di route, e quindi usa la funzione `any()` di Underscore per verificare se la route passa il test (ovvero se la URL corrispondente coincide con il percorso corrente).
Se una qualunque delle route coincide con il path corrente, `any()` restituisce `true`. Per concludere, si utilizza il pattern Javascript `boolean && string`, dove `false && myString` restituisce `false`, ma `true && myString` restituisce invece `myString`.
<%= commit "13-6", "Added active classes to the header." %>
Ora che gli utenti possono votare post in tempo reale, è possibile vedere i post muoversi su e giù nella home page in risposta a cambiamenti nel loro punteggio. Non sarebbe meglio se esistesse il modo di rendere il tutto più fluido con alcune animazioni?