From 0a408c0e48947322cf2748bd8354c29833063d7b Mon Sep 17 00:00:00 2001 From: Alaa Date: Sun, 25 Jan 2026 09:28:40 +0000 Subject: [PATCH] Add rebloom feature --- backend/data/blooms.py | 142 +++++++++++++++++++++++++++++---- backend/endpoints.py | 43 +++++++++- backend/main.py | 2 + db/schema.sql | 8 ++ front-end/components/bloom.mjs | 37 ++++++--- front-end/index.css | 34 ++++++++ front-end/index.html | 7 ++ front-end/lib/api.mjs | 20 +++++ 8 files changed, 265 insertions(+), 28 deletions(-) diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..a4517fd 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -10,9 +10,12 @@ @dataclass class Bloom: id: int - sender: User + sender: str content: str sent_timestamp: datetime.datetime + original_sender: Optional[str] = None + rebloomer: Optional[str] = None + rebloom_count: int = 0 def add_bloom(*, sender: User, content: str) -> Bloom: @@ -20,6 +23,7 @@ def add_bloom(*, sender: User, content: str) -> Bloom: now = datetime.datetime.now(tz=datetime.UTC) bloom_id = int(now.timestamp() * 1000000) + timestamp = datetime.datetime.now(datetime.UTC) 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)", @@ -27,7 +31,7 @@ def add_bloom(*, sender: User, content: str) -> Bloom: bloom_id=bloom_id, sender_id=sender.id, content=content, - timestamp=datetime.datetime.now(datetime.UTC), + timestamp=timestamp, ), ) for hashtag in hashtags: @@ -35,6 +39,14 @@ def add_bloom(*, sender: User, content: str) -> Bloom: "INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)", dict(hashtag=hashtag, bloom_id=bloom_id), ) + + return Bloom( + id=bloom_id, + sender=sender.username, + content=content, + sent_timestamp=timestamp, + rebloom_count=0, + ) def get_blooms_for_user( @@ -54,7 +66,8 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, + (SELECT COUNT(*) FROM reblooms WHERE reblooms.original_bloom_id = blooms.id) as rebloom_count FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE @@ -66,35 +79,39 @@ def get_blooms_for_user( kwargs, ) rows = cur.fetchall() - blooms = [] + blooms_list = [] for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( + bloom_id, sender_username, content, timestamp, rebloom_count = row + blooms_list.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_count=rebloom_count or 0, ) ) - return blooms + return blooms_list 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", + """SELECT blooms.id, users.username, content, send_timestamp, + (SELECT COUNT(*) FROM reblooms WHERE reblooms.original_bloom_id = blooms.id) as rebloom_count + FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s""", (bloom_id,), ) row = cur.fetchone() if row is None: return None - bloom_id, sender_username, content, timestamp = row + bloom_id, sender_username, content, timestamp, rebloom_count = row return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_count=rebloom_count or 0, ) @@ -108,7 +125,8 @@ def get_blooms_with_hashtag( with db_cursor() as cur: cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + blooms.id, users.username, content, send_timestamp, + (SELECT COUNT(*) FROM reblooms WHERE reblooms.original_bloom_id = blooms.id) as rebloom_count FROM blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id WHERE @@ -119,18 +137,19 @@ def get_blooms_with_hashtag( kwargs, ) rows = cur.fetchall() - blooms = [] + blooms_list = [] for row in rows: - bloom_id, sender_username, content, timestamp = row - blooms.append( + bloom_id, sender_username, content, timestamp, rebloom_count = row + blooms_list.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloom_count=rebloom_count or 0, ) ) - return blooms + return blooms_list def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: @@ -140,3 +159,98 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str: else: limit_clause = "" return limit_clause + + +def add_rebloom(*, rebloomer: User, original_bloom_id: int) -> Bloom: + with db_cursor() as cur: + cur.execute( + "SELECT blooms.id, blooms.sender_id, blooms.content, blooms.send_timestamp FROM blooms WHERE blooms.id = %s", + (original_bloom_id,), + ) + row = cur.fetchone() + if row is None: + raise ValueError(f"Bloom {original_bloom_id} does not exist") + + bloom_id, original_sender_id, content, _ = row + + cur.execute( + "SELECT id FROM reblooms WHERE rebloomer_id = %s AND original_bloom_id = %s", + (rebloomer.id, original_bloom_id), + ) + if cur.fetchone() is not None: + raise ValueError("You have already rebloomed this bloom") + + # Add rebloom record + rebloom_timestamp = datetime.datetime.now(datetime.UTC) + cur.execute( + "INSERT INTO reblooms (rebloomer_id, original_bloom_id, rebloom_timestamp) VALUES (%s, %s, %s)", + (rebloomer.id, original_bloom_id, rebloom_timestamp), + ) + + cur.execute("SELECT username FROM users WHERE id = %s", (original_sender_id,)) + original_sender_row = cur.fetchone() + original_sender_username = original_sender_row[0] if original_sender_row else None + + # Return a Bloom object representing the rebloom + # For reblooms, we use the original bloom's data but mark it as rebloomed + return Bloom( + id=bloom_id, # Keep original bloom ID + sender=rebloomer.username, # Show rebloomer as sender in feed + content=content, + sent_timestamp=rebloom_timestamp, # Use rebloom timestamp for feed ordering + original_sender=original_sender_username, + rebloomer=rebloomer.username, + rebloom_count=0, # Will be calculated separately + ) + + +def get_rebloom_count(bloom_id: int) -> int: + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM reblooms WHERE original_bloom_id = %s", + (bloom_id,), + ) + row = cur.fetchone() + return row[0] if row else 0 + + +def get_reblooms_for_user(username: str, *, limit: Optional[int] = None) -> List[Bloom]: + """Get all reblooms made by a user (for their feed)""" + with db_cursor() as cur: + kwargs = {"username": username} + limit_clause = make_limit_clause(limit, kwargs) + + cur.execute( + f"""SELECT + blooms.id, + rebloomer_user.username as rebloomer_username, + original_sender_user.username as original_sender_username, + blooms.content, + reblooms.rebloom_timestamp, + (SELECT COUNT(*) FROM reblooms r2 WHERE r2.original_bloom_id = blooms.id) as rebloom_count + FROM reblooms + INNER JOIN blooms ON reblooms.original_bloom_id = blooms.id + INNER JOIN users rebloomer_user ON reblooms.rebloomer_id = rebloomer_user.id + INNER JOIN users original_sender_user ON blooms.sender_id = original_sender_user.id + WHERE rebloomer_user.username = %(username)s + ORDER BY reblooms.rebloom_timestamp DESC + {limit_clause} + """, + kwargs, + ) + rows = cur.fetchall() + reblooms_list = [] + for row in rows: + bloom_id, rebloomer_username, original_sender_username, content, rebloom_timestamp, rebloom_count = row + reblooms_list.append( + Bloom( + id=bloom_id, + sender=rebloomer_username, + content=content, + sent_timestamp=rebloom_timestamp, + original_sender=original_sender_username, + rebloomer=rebloomer_username, + rebloom_count=rebloom_count or 0, + ) + ) + return reblooms_list diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..20a22de 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -167,6 +167,35 @@ def send_bloom(): ) +@jwt_required() +def rebloom(): + type_check_error = verify_request_fields({"bloom_id": int}) + if type_check_error is not None: + return type_check_error + + user = get_current_user() + bloom_id = request.json["bloom_id"] + + try: + rebloomed_bloom = blooms.add_rebloom(rebloomer=user, original_bloom_id=bloom_id) + return jsonify( + { + "success": True, + "bloom": rebloomed_bloom, + } + ) + except ValueError as e: + return make_response( + ( + { + "success": False, + "message": str(e), + }, + 400, + ) + ) + + def get_bloom(id_str): try: id_int = int(id_str) @@ -192,11 +221,21 @@ def home_timeline(): # Flatten list of blooms from followed users followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] + # Get reblooms from followed users + nested_user_reblooms = [ + blooms.get_reblooms_for_user(followed_user, limit=50) + for followed_user in followed_users + ] + followed_reblooms = [bloom for blooms in nested_user_reblooms for bloom in blooms] + # Get the current user's own blooms own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + + # Get the current user's own reblooms + own_reblooms = blooms.get_reblooms_for_user(current_user.username, limit=50) - # Combine own blooms with followed blooms - all_blooms = followed_blooms + own_blooms + # Combine own blooms with followed blooms and reblooms + all_blooms = followed_blooms + followed_reblooms + own_blooms + own_reblooms # Sort by timestamp (newest first) sorted_blooms = list( diff --git a/backend/main.py b/backend/main.py index 7ba155f..890f90f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ login, other_profile, register, + rebloom, self_profile, send_bloom, suggested_follows, @@ -60,6 +61,7 @@ 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=rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580..ab009ef 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -26,3 +26,11 @@ CREATE TABLE hashtags ( bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +CREATE TABLE reblooms ( + id SERIAL PRIMARY KEY, + rebloomer_id INT NOT NULL REFERENCES users(id), + original_bloom_id BIGINT NOT NULL REFERENCES blooms(id), + rebloom_timestamp TIMESTAMP NOT NULL, + UNIQUE(rebloomer_id, original_bloom_id) +); diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..5416f7c 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,15 +1,5 @@ -/** - * Create a bloom component - * @param {string} template - The ID of the template to clone - * @param {Object} bloom - The bloom data - * @returns {DocumentFragment} - The bloom fragment of UI, for items in the Timeline - * btw a bloom object is composed thus - * {"id": Number, - * "sender": username, - * "content": "string from textarea", - * "sent_timestamp": "datetime as ISO 8601 formatted string"} - - */ +import {apiService} from "../lib/api.mjs"; + const createBloom = (template, bloom) => { if (!bloom) return; const bloomFrag = document.getElementById(template).content.cloneNode(true); @@ -20,6 +10,10 @@ 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 rebloomHeader = bloomFrag.querySelector("[data-rebloom-header]"); + const rebloomIndicator = bloomFrag.querySelector("[data-rebloom-indicator]"); + const rebloomBtn = bloomFrag.querySelector("[data-rebloom-btn]"); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); @@ -31,9 +25,28 @@ const createBloom = (template, bloom) => { .body.childNodes ); + if (bloom.rebloomer && bloom.original_sender) { + rebloomHeader.style.display = "block"; + rebloomIndicator.textContent = `${bloom.sender} rebloomed ${bloom.original_sender}`; + bloomArticle.classList.add("bloom--rebloomed"); + } + + if (bloom.rebloom_count > 0) { + rebloomCount.style.display = "inline"; + rebloomCount.textContent = `${bloom.rebloom_count} rebloom${bloom.rebloom_count !== 1 ? 's' : ''}`; + } + + if (rebloomBtn) { + rebloomBtn.addEventListener("click", () => handleRebloom(bloom.id)); + } + return bloomFrag; }; +async function handleRebloom(bloomId) { + await apiService.rebloom(bloomId); +} + function _formatHashtags(text) { if (!text) return text; return text.replace( diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..62a3b2b 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -221,6 +221,40 @@ dialog { gap: var(--space); } +/* BLOOM */ +.bloom { + position: relative; +} + +.bloom--rebloomed { + border-left: 3px solid var(--brand); + padding-left: calc(var(--space) - 3px); +} + +.bloom__rebloom-indicator { + font-size: 0.9em; + color: var(--accent); + font-style: italic; +} + +.bloom__actions { + display: flex; + align-items: center; + gap: calc(var(--space) / 2); + margin-top: calc(var(--space) / 2); +} + +.bloom__rebloom-btn { + font-size: 0.9em; + padding: calc(var(--space) / 4) calc(var(--space) / 2); +} + +.bloom__rebloom-count { + font-size: 0.85em; + color: var(--accent); + font-style: italic; +} + /* states, helpers*/ .flex { display: flex; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..b0310cb 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -234,11 +234,18 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..112d98b 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,25 @@ async function postBloom(content) { } } +async function rebloom(bloomId) { + try { + const data = await _apiRequest("/rebloom", { + method: "POST", + body: JSON.stringify({bloom_id: bloomId}), + }); + + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + // Error already handled by _apiRequest + return {success: false}; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -291,6 +310,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloom, getBloomsByHashtag, // User methods