From 75a24c1407ff2e9894f30a9ac8e3d9aa0a577441 Mon Sep 17 00:00:00 2001 From: Zobeir-Rigi Date: Sat, 4 Jul 2026 16:54:48 +0100 Subject: [PATCH] Add rebloom functionality --- backend/data/blooms.py | 141 ++++++++++++++++++++++----------- backend/endpoints.py | 36 +++++++-- backend/main.py | 6 ++ db/schema.sql | 3 +- front-end/components/bloom.mjs | 9 ++- front-end/index.html | 11 ++- front-end/lib/api.mjs | 20 ++++- 7 files changed, 170 insertions(+), 56 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..6cfbbe48 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -10,24 +10,95 @@ @dataclass class Bloom: id: int - sender: User + sender: str content: str sent_timestamp: datetime.datetime + rebloom_of: int | None = None + original_sender: str | None = None + original_content: str | None = None + original_sent_timestamp: datetime.datetime | None = None + rebloom_count: int = 0 + + def to_dict(self) -> Dict[str, Any]: + return _make_bloom_dict(self) + + +def _make_bloom_dict(bloom: Bloom) -> Dict[str, Any]: + return { + "id": bloom.id, + "sender": bloom.sender, + "content": bloom.content, + "sent_timestamp": bloom.sent_timestamp, + "rebloom_of": bloom.rebloom_of, + "original_sender": bloom.original_sender, + "original_content": bloom.original_content, + "original_sent_timestamp": bloom.original_sent_timestamp, + "rebloom_count": bloom.rebloom_count, + } -def add_bloom(*, sender: User, content: str) -> Bloom: +def _row_to_bloom(row): + ( + bloom_id, + sender_username, + content, + timestamp, + rebloom_of, + original_sender, + original_content, + original_timestamp, + rebloom_count, + ) = row + return Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + rebloom_of=rebloom_of, + original_sender=original_sender, + original_content=original_content, + original_sent_timestamp=original_timestamp, + rebloom_count=rebloom_count or 0, + ) + + +def _default_bloom_query(): + return """SELECT + blooms.id, + users.username, + blooms.content, + blooms.send_timestamp, + blooms.rebloom_of, + original_users.username AS original_sender, + original_blooms.content AS original_content, + original_blooms.send_timestamp AS original_sent_timestamp, + COALESCE(rebloom_counts.num_rebloomed, 0) AS rebloom_count + FROM blooms + INNER JOIN users ON users.id = blooms.sender_id + LEFT JOIN blooms AS original_blooms ON original_blooms.id = blooms.rebloom_of + LEFT JOIN users AS original_users ON original_users.id = original_blooms.sender_id + LEFT JOIN ( + SELECT rebloom_of, COUNT(*) AS num_rebloomed + FROM blooms + WHERE rebloom_of IS NOT NULL + GROUP BY rebloom_of + ) rebloom_counts ON rebloom_counts.rebloom_of = blooms.id""" + + +def add_bloom(*, sender: User, content: str, rebloom_of=None) -> Bloom: hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")] now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) with db_cursor() as cur: cur.execute( - "INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)", + "INSERT INTO blooms (id, sender_id, content, send_timestamp, rebloom_of) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(rebloom_of)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, timestamp=datetime.datetime.now(datetime.UTC), + rebloom_of=rebloom_of, ), ) for hashtag in hashtags: @@ -36,6 +107,14 @@ def add_bloom(*, sender: User, content: str) -> Bloom: dict(hashtag=hashtag, bloom_id=bloom_id), ) + return Bloom( + id=bloom_id, + sender=sender.username, + content=content, + sent_timestamp=now, + rebloom_of=rebloom_of, + ) + def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None @@ -45,7 +124,7 @@ def get_blooms_for_user( "sender_username": username, } if before is not None: - before_clause = "AND send_timestamp < %(before_limit)s" + before_clause = "AND blooms.send_timestamp < %(before_limit)s" kwargs["before_limit"] = before else: before_clause = "" @@ -53,49 +132,30 @@ def get_blooms_for_user( limit_clause = make_limit_clause(limit, kwargs) cur.execute( - f"""SELECT - blooms.id, users.username, content, send_timestamp - FROM - blooms INNER JOIN users ON users.id = blooms.sender_id + f"""{_default_bloom_query()} WHERE - username = %(sender_username)s + users.username = %(sender_username)s {before_clause} - ORDER BY send_timestamp DESC + ORDER BY blooms.send_timestamp DESC {limit_clause} """, kwargs, ) rows = cur.fetchall() - blooms = [] - for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) - ) - return blooms + return [_row_to_bloom(row) for row in rows] def get_bloom(bloom_id: int) -> Optional[Bloom]: with db_cursor() as cur: cur.execute( - "SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s", + f"""{_default_bloom_query()} + WHERE blooms.id = %s""", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row - return Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) + return _row_to_bloom(row) def get_blooms_with_hashtag( @@ -107,30 +167,17 @@ def get_blooms_with_hashtag( limit_clause = make_limit_clause(limit, kwargs) with db_cursor() as cur: cur.execute( - f"""SELECT - blooms.id, users.username, content, send_timestamp - FROM - blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id + f"""{_default_bloom_query()} + INNER JOIN hashtags ON blooms.id = hashtags.bloom_id WHERE hashtag = %(hashtag_without_leading_hash)s - ORDER BY send_timestamp DESC + ORDER BY blooms.send_timestamp DESC {limit_clause} """, kwargs, ) rows = cur.fetchall() - blooms = [] - for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( - Bloom( - id=bloom_id, - sender=sender_username, - content=content, - sent_timestamp=timestamp, - ) - ) - return blooms + return [_row_to_bloom(row) for row in rows] def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..7d7541db 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -115,7 +115,7 @@ def other_profile(profile_username): return jsonify( { "username": profile_username, - "recent_blooms": all_blooms[:10], + "recent_blooms": [bloom.to_dict() for bloom in all_blooms[:10]], "follows": get_followed_usernames(profile_user), "followers": list(followers), "is_following": current_user is not None @@ -175,7 +175,7 @@ def get_bloom(id_str): bloom = blooms.get_bloom(id_int) if bloom is None: return make_response((f"Bloom not found", 404)) - return jsonify(bloom) + return jsonify(bloom.to_dict()) @jwt_required() @@ -203,13 +203,13 @@ def home_timeline(): sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) ) - return jsonify(sorted_blooms) + return jsonify([bloom.to_dict() for bloom in sorted_blooms]) def user_blooms(profile_username): user_blooms = blooms.get_blooms_for_user(profile_username) user_blooms.reverse() - return jsonify(user_blooms) + return jsonify([bloom.to_dict() for bloom in user_blooms]) @jwt_required() @@ -229,7 +229,7 @@ def suggested_follows(limit_str): def hashtag(hashtag): - return jsonify(blooms.get_blooms_with_hashtag(hashtag)) + return jsonify([bloom.to_dict() for bloom in blooms.get_blooms_with_hashtag(hashtag)]) def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, None]: @@ -245,3 +245,29 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No ) ) return None + +@jwt_required() +def do_rebloom(bloom_id): + try: + bloom_id = int(bloom_id) + except ValueError: + return make_response(("Invalid bloom id", 400)) + + original_bloom = blooms.get_bloom(bloom_id) + + if original_bloom is None: + return make_response(("Bloom not found", 404)) + + current_user = get_current_user() + + blooms.add_bloom( + sender=current_user, + content=original_bloom.content, + rebloom_of=bloom_id, + ) + + return jsonify( + { + "success": True, + } + ) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 7ba155fa..df1842eb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from custom_json_provider import CustomJsonProvider from data.users import lookup_user from endpoints import ( + do_rebloom, do_follow, get_bloom, hashtag, @@ -60,6 +61,11 @@ def main(): app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) + app.add_url_rule( + "/rebloom/", + methods=["POST"], + view_func=do_rebloom, +) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..0869e727 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,7 +10,8 @@ CREATE TABLE blooms ( id BIGSERIAL NOT NULL PRIMARY KEY, sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, - send_timestamp TIMESTAMP NOT NULL + send_timestamp TIMESTAMP NOT NULL, + rebloom_of BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..81e2ca7e 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -10,6 +10,8 @@ * "sent_timestamp": "datetime as ISO 8601 formatted string"} */ +import {apiService} from "../index.mjs"; + const createBloom = (template, bloom) => { if (!bloom) return; const bloomFrag = document.getElementById(template).content.cloneNode(true); @@ -20,6 +22,9 @@ const createBloom = (template, bloom) => { const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomButton = bloomFrag.querySelector( + "[data-action='rebloom']" +); bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -30,7 +35,9 @@ const createBloom = (template, bloom) => { ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") .body.childNodes ); - +rebloomButton?.addEventListener("click", () => { + apiService.rebloomBloom(bloom.id); +}); return bloomFrag; }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..49f83429 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -236,9 +236,18 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..837fd79a 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -281,12 +281,28 @@ async function unfollowUser(username) { } } +async function rebloomBloom(bloomId) { + try { + const data = await _apiRequest(`/rebloom/${bloomId}`, { + method: "POST", + }); + + if (data.success) { + await getBlooms(); + } + + return data; + } catch (error) { + return {success: false}; + } +} + const apiService = { // Auth methods login, signup, logout, - + rebloomBloom, // Bloom methods getBloom, getBlooms, @@ -300,4 +316,6 @@ const apiService = { getWhoToFollow, }; + + export {apiService};