diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..42f664f5 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -10,24 +10,34 @@ @dataclass class Bloom: id: int - sender: User + sender: str content: str sent_timestamp: datetime.datetime - - -def add_bloom(*, sender: User, content: str) -> Bloom: + original_bloom_id: Optional[int] = None + original_sender: Optional[str] = None + original_sent_timestamp: Optional[datetime.datetime] = None + rebloom_count: int = 0 + + +def add_bloom( + *, + sender: User, + content: str, + original_bloom_id: Optional[int] = 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, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(original_bloom_id)s)", dict( bloom_id=bloom_id, sender_id=sender.id, content=content, - timestamp=datetime.datetime.now(datetime.UTC), + timestamp=now, + original_bloom_id=original_bloom_id, ), ) for hashtag in hashtags: @@ -35,6 +45,36 @@ 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=now, + original_bloom_id=original_bloom_id, + ) + + +def _row_to_bloom(row): + ( + bloom_id, + sender_username, + content, + timestamp, + original_bloom_id, + original_send_timestamp, + original_sender_username, + rebloom_count, + ) = row + return Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + original_bloom_id=original_bloom_id, + original_sent_timestamp=original_send_timestamp, + original_sender=original_sender_username, + rebloom_count=rebloom_count, + ) def get_blooms_for_user( @@ -45,7 +85,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 b.send_timestamp < %(before_limit)s" kwargs["before_limit"] = before else: before_clause = "" @@ -54,48 +94,57 @@ def get_blooms_for_user( 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 + b.id, + sender.username, + b.content, + b.send_timestamp, + b.original_bloom_id, + original_bloom.send_timestamp AS original_send_timestamp, + original_sender.username AS original_sender_username, + COUNT(rebloom_child.id) AS rebloom_count + FROM blooms b + INNER JOIN users sender ON sender.id = b.sender_id + LEFT JOIN blooms original_bloom ON original_bloom.id = b.original_bloom_id + LEFT JOIN users original_sender ON original_sender.id = original_bloom.sender_id + LEFT JOIN blooms rebloom_child ON rebloom_child.original_bloom_id = b.id + WHERE sender.username = %(sender_username)s {before_clause} - ORDER BY send_timestamp DESC + GROUP BY b.id, sender.username, b.content, b.send_timestamp, b.original_bloom_id, original_bloom.send_timestamp, original_sender.username + ORDER BY b.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", + """SELECT + b.id, + sender.username, + b.content, + b.send_timestamp, + b.original_bloom_id, + original_bloom.send_timestamp AS original_send_timestamp, + original_sender.username AS original_sender_username, + COUNT(rebloom_child.id) AS rebloom_count + FROM blooms b + INNER JOIN users sender ON sender.id = b.sender_id + LEFT JOIN blooms original_bloom ON original_bloom.id = b.original_bloom_id + LEFT JOIN users original_sender ON original_sender.id = original_bloom.sender_id + LEFT JOIN blooms rebloom_child ON rebloom_child.original_bloom_id = b.id + WHERE b.id = %s + GROUP BY b.id, sender.username, b.content, b.send_timestamp, b.original_bloom_id, original_bloom.send_timestamp, original_sender.username + """, (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( @@ -108,29 +157,29 @@ def get_blooms_with_hashtag( 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 - WHERE - hashtag = %(hashtag_without_leading_hash)s - ORDER BY send_timestamp DESC + b.id, + sender.username, + b.content, + b.send_timestamp, + b.original_bloom_id, + original_bloom.send_timestamp AS original_send_timestamp, + original_sender.username AS original_sender_username, + COUNT(rebloom_child.id) AS rebloom_count + FROM blooms b + INNER JOIN hashtags ON b.id = hashtags.bloom_id + INNER JOIN users sender ON b.sender_id = sender.id + LEFT JOIN blooms original_bloom ON original_bloom.id = b.original_bloom_id + LEFT JOIN users original_sender ON original_sender.id = original_bloom.sender_id + LEFT JOIN blooms rebloom_child ON rebloom_child.original_bloom_id = b.id + WHERE hashtag = %(hashtag_without_leading_hash)s + GROUP BY b.id, sender.username, b.content, b.send_timestamp, b.original_bloom_id, original_bloom.send_timestamp, original_sender.username + ORDER BY b.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/main.py b/backend/main.py index 7ba155fa..670f6b77 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ login, other_profile, register, + rebloom, self_profile, send_bloom, suggested_follows, @@ -57,6 +58,7 @@ def main(): app.add_url_rule("/suggested-follows/", view_func=suggested_follows) app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom) + app.add_url_rule("/bloom//rebloom", methods=["POST"], view_func=rebloom) 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) 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..b90ad6d4 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -19,6 +19,9 @@ const createBloom = (template, bloom) => { const bloomUsername = bloomFrag.querySelector("[data-username]"); const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); + const rebloomInfo = bloomFrag.querySelector("[data-rebloom-info]"); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomButton = bloomFrag.querySelector("[data-action='rebloom']"); const bloomContent = bloomFrag.querySelector("[data-content]"); bloomArticle.setAttribute("data-bloom-id", bloom.id); @@ -31,6 +34,26 @@ const createBloom = (template, bloom) => { .body.childNodes ); + if (bloom.original_sender) { + rebloomInfo.hidden = false; + const originalTime = bloom.original_sent_timestamp + ? ` • ${_formatTimestamp(bloom.original_sent_timestamp)}` + : ""; + rebloomInfo.innerHTML = `Rebloomed from @${bloom.original_sender}${originalTime}`; + } + + if (bloom.rebloom_count && bloom.rebloom_count > 0) { + rebloomCount.hidden = false; + rebloomCount.textContent = + bloom.rebloom_count === 1 + ? "1 rebloom" + : `${bloom.rebloom_count} reblooms`; + } + + if (rebloomButton) { + rebloomButton.dataset.bloomId = bloom.id; + } + return bloomFrag; }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..8a614b27 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -238,6 +238,13 @@

Share a Bloom

Username +
+ + + +
diff --git a/front-end/index.mjs b/front-end/index.mjs index be49922d..58c0892e 100644 --- a/front-end/index.mjs +++ b/front-end/index.mjs @@ -40,6 +40,18 @@ async function init() { handleRouteChange(); + const timelineContainer = getTimelineContainer(); + timelineContainer?.addEventListener("click", async (event) => { + const rebloomButton = event.target.closest("[data-action='rebloom']"); + if (!rebloomButton) return; + + const bloomArticle = rebloomButton.closest("[data-bloom]"); + const bloomId = bloomArticle?.getAttribute("data-bloom-id"); + if (!bloomId) return; + + await apiService.rebloom(bloomId); + }); + document.addEventListener("state-change", () => { handleRouteChange(); }); diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..a18d3d19 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -212,6 +212,23 @@ async function postBloom(content) { } } +async function rebloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${bloomId}/rebloom`, { + method: "POST", + }); + + if (data.success) { + await getBlooms(); + await getProfile(state.currentUser); + } + + return data; + } catch (error) { + return {success: false}; + } +} + // ======= USER methods async function getProfile(username) { const endpoint = username ? `/profile/${username}` : "/profile"; @@ -291,6 +308,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloom, getBloomsByHashtag, // User methods