Skip to content

Commit e6ad154

Browse files
Manishrdyclaude
andcommitted
Community board: downvotes, familiar vote labels, UI polish
- Voting now works like Reddit. Each user has one vote per post, up (+1) or down (-1). Casting the same vote again clears it, and the opposite vote switches it. - CommunityVote gains a value column, and CommunityPost gains downvote_count and score. Score is up minus down and drives the Top sort. On first boot add_community backfills existing votes as upvotes. - Only upvotes carry over when a duplicate is merged, and only upvoters get the "fixed" notification. - Vote labels: questions, features, feedback and announcements use Upvote / Downvote. Bugs use Me too / Can't reproduce. - List cards get an up/down vote rail with the score and vote in place, so the post doesn't have to be opened. The post page gets a labelled vote bar with separate counts. - UI polish: - header card - Active / New / Top tabs - flair chips that scroll sideways on phones - "Before you post" sidebar - flair colour accents on cards and posts - per-name avatar colours - numbered bug steps - Expected / Actual boxes edged green and red - flair picker cards with descriptions in the new-post form - refreshed admin and notification pages - Mobile fixes: the page no longer scrolls sideways at 375px, vote buttons stay on one line, and screenshots sit side by side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e2f8854 commit e6ad154

13 files changed

Lines changed: 652 additions & 330 deletions

File tree

‎database/models.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,7 +1321,9 @@ class CommunityPost(db.Model):
13211321
# True while the latest word on the thread is not an admin's
13221322
needs_admin = db.Column(db.Boolean, nullable=False, default=True, index=True)
13231323

1324-
vote_count = db.Column(db.Integer, nullable=False, default=0)
1324+
vote_count = db.Column(db.Integer, nullable=False, default=0) # upvotes
1325+
downvote_count = db.Column(db.Integer, nullable=False, default=0)
1326+
score = db.Column(db.Integer, nullable=False, default=0, index=True) # up - down, drives "Top"
13251327
comment_count = db.Column(db.Integer, nullable=False, default=0)
13261328

13271329
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
@@ -1365,12 +1367,13 @@ class CommunityComment(db.Model):
13651367

13661368

13671369
class CommunityVote(db.Model):
1368-
"""One "me too" / "I want this" per user per post."""
1370+
"""One vote per user per post: +1 (upvote / "Me too") or -1 (downvote)."""
13691371
__tablename__ = 'community_votes'
13701372

13711373
id = db.Column(db.Integer, primary_key=True)
13721374
post_id = db.Column(db.Integer, db.ForeignKey('community_posts.id', ondelete='CASCADE'), nullable=False, index=True)
13731375
user_id = db.Column(db.String(120), db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
1376+
value = db.Column(db.Integer, nullable=False, default=1)
13741377
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
13751378

13761379
__table_args__ = (

‎migrations/add_community.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def run_migration(db, app):
6363
conn.execute(text("ALTER TABLE users ADD COLUMN community_muted_until DATETIME"))
6464
print("[Migration] add_community: added users.community_muted_until.")
6565

66+
had_score = (conn.dialect.has_table(conn, "community_posts")
67+
and "score" in _columns(conn, "community_posts"))
6668
for name in COMMUNITY_MODELS:
6769
table = getattr(models, name).__table__
6870
if not conn.dialect.has_table(conn, table.name):
@@ -73,6 +75,16 @@ def run_migration(db, app):
7375
for index in table.indexes:
7476
index.create(bind=conn, checkfirst=True)
7577

78+
if not had_score:
79+
# Downvotes arrived after the first release: every existing
80+
# vote is an upvote (value defaulted to 1); derive the counters.
81+
conn.execute(text("""
82+
UPDATE community_posts SET
83+
vote_count = (SELECT COUNT(*) FROM community_votes v WHERE v.post_id = community_posts.id AND v.value > 0),
84+
downvote_count = (SELECT COUNT(*) FROM community_votes v WHERE v.post_id = community_posts.id AND v.value < 0)
85+
"""))
86+
conn.execute(text("UPDATE community_posts SET score = vote_count - downvote_count"))
87+
7688
if ensure_fts(conn):
7789
print("[Migration] add_community: created community_posts_fts.")
7890

‎routes/community_routes.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import re
9+
import zlib
910
from datetime import datetime
1011

1112
from markupsafe import Markup, escape
@@ -61,6 +62,7 @@ def time_ago(iso) -> str:
6162
def register_community_routes(app, *, db=db, limiter=None):
6263
app.jinja_env.filters["cm_linkify"] = linkify
6364
app.jinja_env.filters["cm_ago"] = time_ago
65+
app.jinja_env.filters["cm_hue"] = lambda name: zlib.crc32((name or "").encode()) % 360
6466

6567
def limit(rule):
6668
if limiter is None:
@@ -81,10 +83,11 @@ def community_index():
8183
"q": (args.get("q") or "").strip()[:100] or None,
8284
}
8385
pinned, posts, next_cursor = cs.list_posts(current_user, cursor=args.get("cursor"), **filters)
86+
votes = cs.my_votes(pinned + posts, current_user)
8487
return render_template(
8588
"community/index.html",
86-
pinned=[cs.serialize_post(p, current_user) for p in pinned],
87-
posts=[cs.serialize_post(p, current_user) for p in posts],
89+
pinned=[cs.serialize_post(p, current_user, vote=votes.get(p.id, 0)) for p in pinned],
90+
posts=[cs.serialize_post(p, current_user, vote=votes.get(p.id, 0)) for p in posts],
8891
next_cursor=next_cursor, filters=filters,
8992
flairs=cs.FLAIRS, statuses=cs.STATUSES,
9093
block=cs.posting_block_reason(current_user),
@@ -205,13 +208,17 @@ def community_api_delete(public_id):
205208
@login_required
206209
@limit("60 per minute")
207210
def community_api_vote(public_id):
211+
try:
212+
value = int(_json_body().get("value", 1))
213+
except (TypeError, ValueError):
214+
return jsonify({"error": "Vote must be up or down."}), 400
208215
try:
209216
post = cs.get_visible_post(public_id, current_user)
210-
voted, count = cs.toggle_vote(post, current_user)
217+
mine = cs.cast_vote(post, current_user, value)
211218
except cs.CommunityError as exc:
212219
db.session.rollback()
213220
return _error(exc)
214-
return jsonify({"voted": voted, "vote_count": count})
221+
return jsonify({"vote": mine, "up": post.vote_count, "down": post.downvote_count, "score": post.score})
215222

216223
@app.route("/api/community/similar")
217224
@login_required

‎services/community_service.py‎

Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,18 @@
3838

3939
# ── Vocabulary ────────────────────────────────────────────────────────────────
4040

41+
# vote_label / down_label name what an upvote / downvote means for the flair.
4142
FLAIRS = {
42-
"bug": {"label": "Bug", "vote_label": "Me too", "icon": "fa-bug"},
43-
"question": {"label": "Question", "vote_label": "Same question", "icon": "fa-circle-question"},
44-
"feature": {"label": "Feature request", "vote_label": "I want this", "icon": "fa-lightbulb"},
45-
"feedback": {"label": "Feedback", "vote_label": "Agree", "icon": "fa-comment-dots"},
46-
"announcement": {"label": "Announcement", "vote_label": "Noted", "icon": "fa-bullhorn", "admin_only": True},
43+
"bug": {"label": "Bug", "vote_label": "Me too", "down_label": "Can't reproduce",
44+
"icon": "fa-bug", "hint": "Something is broken or behaves wrongly"},
45+
"question": {"label": "Question", "vote_label": "Upvote", "down_label": "Downvote",
46+
"icon": "fa-circle-question", "hint": "Ask how something works"},
47+
"feature": {"label": "Feature request", "vote_label": "Upvote", "down_label": "Downvote",
48+
"icon": "fa-lightbulb", "hint": "Suggest something new or better"},
49+
"feedback": {"label": "Feedback", "vote_label": "Upvote", "down_label": "Downvote",
50+
"icon": "fa-comment-dots", "hint": "Share what you like or don't"},
51+
"announcement": {"label": "Announcement", "vote_label": "Upvote", "down_label": "Downvote",
52+
"icon": "fa-bullhorn", "hint": "News from the team", "admin_only": True},
4753
}
4854

4955
STATUSES = {
@@ -405,11 +411,19 @@ def restore_post(post: CommunityPost) -> None:
405411
# ── Votes ─────────────────────────────────────────────────────────────────────
406412

407413
def _recount_votes(post: CommunityPost) -> int:
408-
post.vote_count = db.session.query(func.count(CommunityVote.id)).filter_by(post_id=post.id).scalar() or 0
409-
return post.vote_count
410-
411-
412-
def toggle_vote(post: CommunityPost, user) -> tuple[bool, int]:
414+
rows = dict(db.session.query(CommunityVote.value, func.count(CommunityVote.id))
415+
.filter_by(post_id=post.id).group_by(CommunityVote.value).all())
416+
post.vote_count = rows.get(1, 0)
417+
post.downvote_count = rows.get(-1, 0)
418+
post.score = post.vote_count - post.downvote_count
419+
return post.score
420+
421+
422+
def cast_vote(post: CommunityPost, user, value: int = 1) -> int:
423+
"""Reddit-style: +1 / -1; repeating your current vote clears it, the
424+
opposite vote switches it. Returns the user's vote afterwards (1, -1, 0)."""
425+
if value not in (1, -1):
426+
raise CommunityError("Vote must be up or down.")
413427
require_can_write(user, voting=True)
414428
if post.author_id == user.id:
415429
raise CommunityError("You can't vote on your own post.", code="own_post")
@@ -418,24 +432,37 @@ def toggle_vote(post: CommunityPost, user) -> tuple[bool, int]:
418432
if post.deleted_at is not None or post.status == "duplicate":
419433
raise CommunityError("Voting is closed on this post.", code="closed")
420434
existing = CommunityVote.query.filter_by(post_id=post.id, user_id=user.id).first()
421-
if existing:
435+
if existing is not None and existing.value == value:
422436
db.session.delete(existing)
423-
voted = False
437+
mine = 0
438+
elif existing is not None:
439+
existing.value = value
440+
mine = value
424441
else:
425-
db.session.add(CommunityVote(post_id=post.id, user_id=user.id))
426-
voted = True
442+
db.session.add(CommunityVote(post_id=post.id, user_id=user.id, value=value))
443+
mine = value
427444
try:
428445
db.session.flush()
429446
except IntegrityError: # double-click race: the other request won
430447
db.session.rollback()
431-
voted = True
432-
count = _recount_votes(post)
448+
mine = my_vote(post, user)
449+
_recount_votes(post)
433450
db.session.commit()
434-
return voted, count
451+
return mine
435452

436453

437-
def has_voted(post: CommunityPost, user) -> bool:
438-
return CommunityVote.query.filter_by(post_id=post.id, user_id=user.id).first() is not None
454+
def my_vote(post: CommunityPost, user) -> int:
455+
vote = CommunityVote.query.filter_by(post_id=post.id, user_id=user.id).first()
456+
return vote.value if vote is not None else 0
457+
458+
459+
def my_votes(posts, user) -> dict[int, int]:
460+
"""post id -> the user's vote, for a list page in one query."""
461+
ids = [p.id for p in posts]
462+
if not ids:
463+
return {}
464+
return dict(db.session.query(CommunityVote.post_id, CommunityVote.value)
465+
.filter(CommunityVote.user_id == user.id, CommunityVote.post_id.in_(ids)).all())
439466

440467

441468
# ── Comments ──────────────────────────────────────────────────────────────────
@@ -571,7 +598,7 @@ def set_status(post: CommunityPost, admin, status: str) -> None:
571598
label = STATUSES[status]
572599
_notify(post.author_id, "status_change", post, actor=admin, detail=label)
573600
if status == "fixed":
574-
voter_ids = [v.user_id for v in CommunityVote.query.filter_by(post_id=post.id).all()]
601+
voter_ids = [v.user_id for v in CommunityVote.query.filter_by(post_id=post.id, value=1).all()]
575602
for uid in voter_ids:
576603
if uid != post.author_id:
577604
_notify(uid, "status_change", post, actor=admin, detail=label)
@@ -602,15 +629,16 @@ def mark_duplicate(post: CommunityPost, admin, target_public_id: str) -> Communi
602629
if target.duplicate_of_id == post.id:
603630
raise CommunityError("That post is already a duplicate of this one.")
604631
# Move votes (and the duplicate's author, who clearly has the issue too).
605-
carriers = {v.user_id for v in CommunityVote.query.filter_by(post_id=post.id).all()}
632+
# Upvotes ("me too") carry over; downvotes on the duplicate don't.
633+
carriers = {v.user_id for v in CommunityVote.query.filter_by(post_id=post.id, value=1).all()}
606634
if post.author_id:
607635
carriers.add(post.author_id)
608636
already = {v.user_id for v in CommunityVote.query.filter_by(post_id=target.id).all()}
609637
for uid in carriers - already - {target.author_id}:
610-
db.session.add(CommunityVote(post_id=target.id, user_id=uid))
638+
db.session.add(CommunityVote(post_id=target.id, user_id=uid, value=1))
611639
CommunityVote.query.filter_by(post_id=post.id).delete()
612640
post.duplicate_of_id = target.id
613-
post.vote_count = 0
641+
post.vote_count = post.downvote_count = post.score = 0
614642
db.session.flush()
615643
_recount_votes(target)
616644
set_status(post, admin, "duplicate") # commits
@@ -667,7 +695,7 @@ def resolve_report(report: CommunityReport, admin, resolution: str) -> None:
667695

668696
# ── Listing ───────────────────────────────────────────────────────────────────
669697

670-
SORTS = {"active": CommunityPost.last_activity_at, "new": CommunityPost.created_at, "top": CommunityPost.vote_count}
698+
SORTS = {"active": CommunityPost.last_activity_at, "new": CommunityPost.created_at, "top": CommunityPost.score}
671699

672700

673701
def _visible_query(viewer, *, include_deleted=False):
@@ -821,13 +849,24 @@ def image_urls(img: CommunityImage) -> dict:
821849
}
822850

823851

824-
def serialize_post(post: CommunityPost, viewer, *, detail=False) -> dict:
852+
def can_vote(post: CommunityPost, viewer) -> bool:
853+
return (post.author_id != viewer.id and post.deleted_at is None and post.status != "duplicate"
854+
and post.visibility == "public")
855+
856+
857+
def serialize_post(post: CommunityPost, viewer, *, detail=False, vote=None) -> dict:
858+
"""``vote`` is the viewer's vote when the caller already knows it (list
859+
pages batch it via my_votes); otherwise it is looked up for detail views."""
825860
flair = FLAIRS.get(post.flair, FLAIRS["question"])
826861
admin = is_admin(viewer)
862+
if vote is None and detail:
863+
vote = my_vote(post, viewer)
827864
data = {
828865
"id": post.public_id,
829866
"flair": post.flair, "flair_label": flair["label"], "flair_icon": flair["icon"],
830-
"vote_label": flair["vote_label"],
867+
"vote_label": flair["vote_label"], "down_label": flair["down_label"],
868+
"downvote_count": post.downvote_count, "score": post.score,
869+
"my_vote": vote or 0, "can_vote": can_vote(post, viewer),
831870
"title": post.title,
832871
"status": post.status, "status_label": STATUSES.get(post.status, post.status),
833872
"visibility": post.visibility,
@@ -848,14 +887,13 @@ def serialize_post(post: CommunityPost, viewer, *, detail=False) -> dict:
848887
"match_format": post.match_format,
849888
"match_format_label": BUG_FORMATS.get(post.match_format or "", None),
850889
"images": [image_urls(i) for i in post.images if i.purged_at is None or admin],
851-
"voted": has_voted(post, viewer),
890+
"voted": vote == 1,
852891
"duplicate_of": None,
853892
"can": {
854893
"edit": can_edit_post(post, viewer),
855894
"delete": admin or post.author_id == viewer.id,
856895
"comment": can_comment(post, viewer),
857-
"vote": (post.author_id != viewer.id and post.deleted_at is None and post.status != "duplicate"
858-
and post.visibility == "public"),
896+
"vote": can_vote(post, viewer),
859897
"moderate": admin,
860898
},
861899
})
@@ -924,7 +962,7 @@ def admin_queues(limit=50) -> dict:
924962
"private": live.filter(CommunityPost.visibility == "private", CommunityPost.needs_admin.is_(True))
925963
.order_by(CommunityPost.last_activity_at.asc()).limit(limit).all(),
926964
"unanswered": live.filter(CommunityPost.visibility == "public", CommunityPost.needs_admin.is_(True))
927-
.order_by(CommunityPost.vote_count.desc(), CommunityPost.created_at.asc()).limit(limit).all(),
965+
.order_by(CommunityPost.score.desc(), CommunityPost.created_at.asc()).limit(limit).all(),
928966
"reports": CommunityReport.query.filter(CommunityReport.resolved_at.is_(None))
929967
.order_by(CommunityReport.created_at.asc()).limit(limit).all(),
930968
"deleted": CommunityPost.query.filter(CommunityPost.deleted_at.isnot(None))

0 commit comments

Comments
 (0)