diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..d749dbe7 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,8 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + original_bloom: Optional["Bloom"] = None + rebloom_count: int = 0 def add_bloom(*, sender: User, content: str) -> Bloom: @@ -37,6 +39,43 @@ def add_bloom(*, sender: User, content: str) -> Bloom: ) +def rebloom(*, sender: User, original_bloom_id: int) -> Optional[Bloom]: + 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, + original_bloom_id + ) + SELECT + %(bloom_id)s, + %(sender_id)s, + content, + %(timestamp)s, + COALESCE(original_bloom_id, id) + FROM blooms + WHERE id = %(original_bloom_id)s + RETURNING id + """, + { + "bloom_id": bloom_id, + "sender_id": sender.id, + "timestamp": now, + "original_bloom_id": original_bloom_id, + }, + ) + if cur.fetchone() is None: + return None + + return get_bloom(bloom_id) + + def get_blooms_for_user( username: str, *, before: Optional[int] = None, limit: Optional[int] = None ) -> List[Bloom]: @@ -44,8 +83,9 @@ def get_blooms_for_user( kwargs = { "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,51 +93,119 @@ 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 - WHERE - username = %(sender_username)s - {before_clause} - ORDER BY send_timestamp DESC + f""" + SELECT + blooms.id, + users.username, + blooms.content, + blooms.send_timestamp, + blooms.original_bloom_id, + ( + SELECT COUNT(*) + FROM blooms AS reblooms_of_this + WHERE reblooms_of_this.original_bloom_id = COALESCE( + blooms.original_bloom_id, + blooms.id + ) + ) AS rebloom_count + FROM blooms + INNER JOIN users + ON users.id = blooms.sender_id + WHERE users.username = %(sender_username)s + {before_clause} + ORDER BY blooms.send_timestamp DESC {limit_clause} """, 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, + original_bloom_id, + rebloom_count, + ) = row + + original_bloom = ( + get_bloom(original_bloom_id) + if original_bloom_id is not None + else None + ) + + blooms_list.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + original_bloom=original_bloom, + rebloom_count=rebloom_count, ) ) - 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, + blooms.content, + blooms.send_timestamp, + blooms.original_bloom_id, + ( + SELECT COUNT(*) + FROM blooms AS reblooms_of_this + WHERE reblooms_of_this.original_bloom_id = COALESCE( + blooms.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, + original_bloom_id, + rebloom_count, + ) = row + + original_bloom = ( + get_bloom(original_bloom_id) + if original_bloom_id is not None + else None + ) + return Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + original_bloom=original_bloom, + rebloom_count=rebloom_count, ) - def get_blooms_with_hashtag( hashtag_without_leading_hash: str, *, limit: int = None ) -> List[Bloom]: diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..79e55190 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -111,7 +111,6 @@ def other_profile(profile_username): followers = get_inverse_followed_usernames(profile_user) all_blooms = blooms.get_blooms_for_user(profile_username) - all_blooms.reverse() return jsonify( { "username": profile_username, @@ -245,3 +244,25 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No ) ) return None + + +@jwt_required() +def rebloom(id_str): + try: + original_bloom_id = int(id_str) + except ValueError: + return make_response(("Invalid bloom id", 400)) + + current_user = get_current_user() + + original_bloom = blooms.get_bloom(original_bloom_id) + + if original_bloom is None: + return make_response(("Bloom not found", 404)) + + rebloomed = blooms.rebloom( + sender=current_user, + original_bloom_id=original_bloom_id, + ) + + return jsonify(rebloomed) diff --git a/backend/main.py b/backend/main.py index 7ba155fa..b45e21da 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ home_timeline, login, other_profile, + rebloom, register, self_profile, send_bloom, @@ -58,6 +59,7 @@ def main(): app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) app.add_url_rule("/bloom/", methods=["GET"], view_func=get_bloom) + app.add_url_rule("/rebloom/", methods=["POST"], view_func=rebloom) app.add_url_rule("/blooms/", view_func=user_blooms) app.add_url_rule("/hashtag/", view_func=hashtag) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..1351eb28 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, + original_bloom_id BIGINT REFERENCES blooms(id) ); CREATE TABLE follows ( diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..08f250fc 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -10,8 +10,11 @@ * "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); const bloomParser = new DOMParser(); @@ -20,17 +23,52 @@ 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 rebloomInfo = bloomFrag.querySelector("[data-rebloom-info]"); + const rebloomButton = bloomFrag.querySelector("[data-action='rebloom']"); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); + + const isRebloom = Boolean(bloom.original_bloom); + const displayAuthor = isRebloom ? bloom.original_bloom.sender : bloom.sender; + const displayTimestamp = isRebloom ? bloom.original_bloom.sent_timestamp : bloom.sent_timestamp; + const displayContent = isRebloom ? bloom.original_bloom.content : bloom.content; + const displayBloomId = isRebloom ? bloom.original_bloom.id : bloom.id; + + rebloomCount.textContent = bloom.rebloom_count ?? 0; bloomArticle.setAttribute("data-bloom-id", bloom.id); - bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); - bloomUsername.textContent = bloom.sender; - bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); - bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`); + bloomArticle.classList.toggle("bloom--rebloom", isRebloom); + bloomUsername.setAttribute("href", `/profile/${displayAuthor}`); + bloomUsername.textContent = displayAuthor; + bloomTime.textContent = _formatTimestamp(displayTimestamp); + bloomTimeLink.setAttribute("href", `/bloom/${displayBloomId}`); + bloomContent.replaceChildren( - ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") + ...bloomParser.parseFromString(_formatHashtags(displayContent), "text/html") .body.childNodes ); + if (isRebloom) { + rebloomInfo.hidden = false; + rebloomInfo.textContent = `🔁 Rebloomed by ${bloom.sender}`; + } + + // Handle rebloom + rebloomButton.addEventListener("click", async () => { + try { + rebloomButton.disabled = true; + rebloomButton.textContent = "Reblooming..."; + + const rebloomedBloom = await apiService.rebloom(bloom.id); + + rebloomCount.textContent = rebloomedBloom.rebloom_count; + } catch (error) { + console.error("Failed to rebloom:", error); + } finally { + rebloomButton.disabled = false; + rebloomButton.innerHTML = `Rebloom ${rebloomCount.textContent}`; + } + }); + return bloomFrag; }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..342ff39c 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -234,11 +234,21 @@

Share a Bloom

diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..34a4a5b9 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,24 @@ async function postBloom(content) { } } +async function rebloom(bloomId) { + try { + const data = await _apiRequest(`/rebloom/${bloomId}`, { + method: "POST", + }); + + if (data.success !== false) { + 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 +309,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloom, getBloomsByHashtag, // User methods