diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf..2adb9e7 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,22 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + rebloom_count: int = 0 + + +@dataclass +class Rebloom: + rebloomer: str + original_bloom: Bloom + rebloom_timestamp: datetime.datetime + + +@dataclass +class FeedItem: + kind: str + timestamp: datetime.datetime + bloom: Bloom + rebloomer: Optional[str] = None def add_bloom(*, sender: User, content: str) -> Bloom: @@ -23,18 +39,114 @@ def add_bloom(*, sender: User, content: str) -> Bloom: 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)", - dict( - bloom_id=bloom_id, - sender_id=sender.id, - content=content, - timestamp=datetime.datetime.now(datetime.UTC), - ), + { + "bloom_id": bloom_id, + "sender_id": sender.id, + "content": content, + "timestamp": datetime.datetime.now(datetime.UTC), + }, ) for hashtag in hashtags: cur.execute( "INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)", - dict(hashtag=hashtag, bloom_id=bloom_id), + {"hashtag": hashtag, "bloom_id": bloom_id}, + ) + + +def add_rebloom(*, rebloomer: User, original_bloom_id: int) -> bool: + """Create a rebloom event. + + Returns True if a rebloom row was inserted, False if it already existed. + """ + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO reblooms (rebloomer_id, original_bloom_id, rebloom_timestamp) + VALUES (%(rebloomer_id)s, %(original_bloom_id)s, %(rebloom_timestamp)s) + ON CONFLICT (rebloomer_id, original_bloom_id) DO NOTHING + """, + { + "rebloomer_id": rebloomer.id, + "original_bloom_id": original_bloom_id, + "rebloom_timestamp": datetime.datetime.now(datetime.UTC), + }, + ) + return cur.rowcount == 1 + + +def get_rebloom_count(original_bloom_id: int) -> int: + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM reblooms WHERE original_bloom_id = %s", + (original_bloom_id,), + ) + row = cur.fetchone() + return int(row[0]) if row is not None else 0 + + +def get_rebloom_counts(original_bloom_ids: List[int]) -> Dict[int, int]: + if not original_bloom_ids: + return {} + + with db_cursor() as cur: + cur.execute( + """ + SELECT original_bloom_id, COUNT(*) + FROM reblooms + WHERE original_bloom_id = ANY(%(original_bloom_ids)s) + GROUP BY original_bloom_id + """, + {"original_bloom_ids": original_bloom_ids}, + ) + return {row[0]: int(row[1]) for row in cur.fetchall()} + + +def get_reblooms_for_user( + username: str, *, limit: Optional[int] = None +) -> List[Rebloom]: + kwargs = { + "rebloomer_username": username, + } + limit_clause = make_limit_clause(limit, kwargs) + + with db_cursor() as cur: + cur.execute( + f"""SELECT + rebloomer.username, + reblooms.rebloom_timestamp, + original_bloom.id, + original_sender.username, + original_bloom.content, + original_bloom.send_timestamp + FROM + reblooms + INNER JOIN users AS rebloomer ON rebloomer.id = reblooms.rebloomer_id + INNER JOIN blooms AS original_bloom ON original_bloom.id = reblooms.original_bloom_id + INNER JOIN users AS original_sender ON original_sender.id = original_bloom.sender_id + WHERE + rebloomer.username = %(rebloomer_username)s + ORDER BY reblooms.rebloom_timestamp DESC + {limit_clause} + """, + kwargs, + ) + rows = cur.fetchall() + reblooms = [] + for row in rows: + rebloomer_username, rebloom_timestamp, bloom_id, sender_username, content, timestamp = row + reblooms.append( + Rebloom( + rebloomer=rebloomer_username, + original_bloom=Bloom( + id=bloom_id, + sender=sender_username, + content=content, + sent_timestamp=timestamp, + ), + rebloom_timestamp=rebloom_timestamp, + ) ) + return reblooms def get_blooms_for_user( diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a0..a59f347 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -167,6 +167,32 @@ def send_bloom(): ) +@jwt_required() +def rebloom(): + type_check_error = verify_request_fields({"original_bloom_id": int}) + if type_check_error is not None: + return type_check_error + + current_user = get_current_user() + original_bloom_id = request.json["original_bloom_id"] + + original_bloom = blooms.get_bloom(original_bloom_id) + if original_bloom is None: + return make_response(("Original bloom not found", 404)) + + inserted = blooms.add_rebloom( + rebloomer=current_user, + original_bloom_id=original_bloom_id, + ) + + return jsonify( + { + "success": True, + "rebloomed": inserted, + } + ) + + def get_bloom(id_str): try: id_int = int(id_str) @@ -182,28 +208,48 @@ def get_bloom(id_str): def home_timeline(): current_user = get_current_user() - # Get blooms from followed users followed_users = get_followed_usernames(current_user) - nested_user_blooms = [ - blooms.get_blooms_for_user(followed_user, limit=50) - for followed_user in followed_users - ] + relevant_usernames = [*followed_users, current_user.username] + + feed_items = [] + original_bloom_ids = [] + + for username in relevant_usernames: + user_blooms = blooms.get_blooms_for_user(username, limit=50) + original_bloom_ids.extend(bloom.id for bloom in user_blooms) + feed_items.extend( + blooms.FeedItem( + kind="bloom", + timestamp=bloom.sent_timestamp, + bloom=bloom, + ) + for bloom in user_blooms + ) - # Flatten list of blooms from followed users - followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] + user_reblooms = blooms.get_reblooms_for_user(username, limit=50) + original_bloom_ids.extend( + rebloom.original_bloom.id for rebloom in user_reblooms + ) + feed_items.extend( + blooms.FeedItem( + kind="rebloom", + timestamp=rebloom.rebloom_timestamp, + bloom=rebloom.original_bloom, + rebloomer=rebloom.rebloomer, + ) + for rebloom in user_reblooms + ) - # Get the current user's own blooms - own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + rebloom_counts = blooms.get_rebloom_counts(original_bloom_ids) - # Combine own blooms with followed blooms - all_blooms = followed_blooms + own_blooms + for feed_item in feed_items: + feed_item.bloom.rebloom_count = rebloom_counts.get(feed_item.bloom.id, 0) - # Sort by timestamp (newest first) - sorted_blooms = list( - sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) + sorted_feed_items = sorted( + feed_items, key=lambda feed_item: feed_item.timestamp, reverse=True ) - return jsonify(sorted_blooms) + return jsonify(sorted_feed_items) def user_blooms(profile_username): diff --git a/backend/main.py b/backend/main.py index 7ba155f..4b4e2cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ home_timeline, login, other_profile, + rebloom, register, self_profile, send_bloom, @@ -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("/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 61e7580..f3fa352 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -13,6 +13,14 @@ CREATE TABLE blooms ( send_timestamp TIMESTAMP NOT NULL ); +CREATE TABLE reblooms ( + id BIGSERIAL NOT NULL 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) +); + CREATE TABLE follows ( id SERIAL PRIMARY KEY, follower INT NOT NULL REFERENCES users(id), diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c..199f49e 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -15,30 +15,67 @@ const createBloom = (template, bloom) => { const bloomFrag = document.getElementById(template).content.cloneNode(true); const bloomParser = new DOMParser(); + const isRebloomEvent = bloom.kind === "rebloom"; + const sourceBloom = isRebloomEvent ? bloom.bloom : bloom; + const rebloomerUsername = isRebloomEvent ? bloom.rebloomer : null; + const rebloomCount = sourceBloom.rebloom_count || 0; + const bloomArticle = bloomFrag.querySelector("[data-bloom]"); const bloomUsername = bloomFrag.querySelector("[data-username]"); const bloomTime = bloomFrag.querySelector("[data-time]"); const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])"); const bloomContent = bloomFrag.querySelector("[data-content]"); + const rebloomMeta = bloomFrag.querySelector("[data-rebloom-meta]"); + const rebloomLabel = bloomFrag.querySelector("[data-rebloom-label]"); + const rebloomerLink = bloomFrag.querySelector("[data-rebloomer]"); + const rebloomCountBadge = bloomFrag.querySelector("[data-rebloom-count]"); + const rebloomButton = bloomFrag.querySelector('[data-action="rebloom"]'); - 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.dataset.bloomId = String(sourceBloom.id); + bloomUsername.setAttribute("href", `/profile/${sourceBloom.sender}`); + bloomUsername.textContent = sourceBloom.sender; + bloomTime.textContent = _formatTimestamp(sourceBloom.sent_timestamp); + bloomTimeLink.setAttribute("href", `/bloom/${sourceBloom.id}`); bloomContent.replaceChildren( - ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") - .body.childNodes + ...bloomParser.parseFromString( + _formatHashtags(sourceBloom.content), + "text/html", + ).body.childNodes, ); + if (rebloomButton) { + rebloomButton.dataset.bloomId = String(sourceBloom.id); + } + + const rebloomCountText = `• ${rebloomCount} rebloom${rebloomCount === 1 ? "" : "s"}`; + + if (isRebloomEvent) { + rebloomMeta.hidden = false; + rebloomLabel.textContent = "Rebloomed by"; + rebloomerLink.setAttribute("href", `/profile/${rebloomerUsername}`); + rebloomerLink.hidden = false; + rebloomerLink.textContent = rebloomerUsername; + rebloomCountBadge.textContent = rebloomCount > 0 ? rebloomCountText : ""; + rebloomCountBadge.hidden = rebloomCount === 0; + } else if (rebloomCount > 0) { + rebloomMeta.hidden = false; + rebloomLabel.textContent = ""; + rebloomerLink.hidden = true; + rebloomerLink.removeAttribute("href"); + rebloomCountBadge.textContent = `${rebloomCount} rebloom${rebloomCount === 1 ? "" : "s"}`; + rebloomCountBadge.hidden = false; + } else { + rebloomerLink.hidden = true; + } + return bloomFrag; }; function _formatHashtags(text) { if (!text) return text; - return text.replace( + return text.replaceAll( /\B#[^#]+/g, - (match) => `${match}` + (match) => `${match}`, ); } @@ -84,4 +121,29 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +/** + * Handle rebloom button click + * @param {Event} event - The click event from the rebloom button + */ +async function handleRebloom(event) { + const button = event.target; + const bloomId = Number.parseInt(button.dataset.bloomId, 10); + const originalText = button.textContent; + + try { + button.disabled = true; + button.textContent = "Reblooming..."; + + // Call the rebloom API + await globalThis.apiService.rebloom(bloomId); + + // Refresh the timeline to show updated rebloom count + await globalThis.apiService.getBlooms(); + } finally { + // Restore button state + button.textContent = originalText; + button.disabled = false; + } +} + +export { createBloom, handleRebloom }; diff --git a/front-end/index.css b/front-end/index.css index 65c7fb4..dc95555 100644 --- a/front-end/index.css +++ b/front-end/index.css @@ -9,7 +9,8 @@ --paper: hsla(var(--key), 45%, 96%); --ink: hsl(var(--key), 15%, 10%); --outline: hsl(var(--key), 25%, 90%); - --shadow: var(--outline) 0px 0px 0px 1px, var(--outline) 0px 1px 0px 0px, + --shadow: + var(--outline) 0px 0px 0px 1px, var(--outline) 0px 1px 0px 0px, var(--outline) 0px 0px 2.5px 0px, 0px 3.25px 6px var(--outline); --inset: inset 0px -3.25px 12px var(--outline); --corner: 2.5px; @@ -30,7 +31,10 @@ body { grid-template: ". header ." auto ". main ." 1fr - ". footer ." auto / minmax(var(--space), 1fr) var(--container) minmax(var(--space), 1fr); + ". footer ." auto / minmax(var(--space), 1fr) var(--container) minmax( + var(--space), + 1fr + ); header { grid-area: header; display: flex; @@ -94,8 +98,13 @@ main { /* base elements */ html { box-sizing: border-box; - font: 100%/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, sans-serif; + font: + 100%/1.5 system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; background-color: var(--paper); color: var(--ink); } @@ -130,7 +139,9 @@ textarea { } button { - font: 600 100% monospace, system-ui; + font: + 600 100% monospace, + system-ui; white-space: nowrap; color: var(--ink); background-color: transparent; @@ -138,7 +149,8 @@ button { border-radius: var(--pill); box-shadow: 2px 3px var(--brand); padding: calc(var(--space) / 3) var(--space); - transition: all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275), + transition: + all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275), border-color 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); } @@ -221,6 +233,17 @@ dialog { gap: var(--space); } +.bloom__rebloom-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.bloom__rebloom-button { + justify-self: start; +} + /* states, helpers*/ .flex { display: flex; diff --git a/front-end/index.html b/front-end/index.html index 89d6b13..0374615 100644 --- a/front-end/index.html +++ b/front-end/index.html @@ -1,4 +1,4 @@ - + @@ -10,12 +10,7 @@

- Purple Forest + Purple Forest PurpleForest

@@ -189,8 +184,7 @@

Create your account

Who to follow

-
    -
+
    @@ -234,11 +228,31 @@

    Share a Bloom

    diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339..11e5f22 100644 --- a/front-end/lib/api.mjs +++ b/front-end/lib/api.mjs @@ -1,5 +1,5 @@ -import {state} from "../index.mjs"; -import {handleErrorDialog} from "../components/error.mjs"; +import { state } from "../index.mjs"; +import { handleErrorDialog } from "../components/error.mjs"; // === ABOUT THE STATE // state gives you these two functions only @@ -20,13 +20,13 @@ async function _apiRequest(endpoint, options = {}) { const defaultOptions = { headers: { "Content-Type": "application/json", - ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, mode: "cors", credentials: "include", }; - const fetchOptions = {...defaultOptions, ...options}; + const fetchOptions = { ...defaultOptions, ...options }; const url = endpoint.startsWith("http") ? endpoint : `${baseUrl}${endpoint}`; try { @@ -35,7 +35,7 @@ async function _apiRequest(endpoint, options = {}) { if (!response.ok) { const errorData = await response.json().catch(() => ({})); const error = new Error( - errorData.message || `API error: ${response.status}` + errorData.message || `API error: ${response.status}`, ); error.status = response.status; @@ -54,7 +54,7 @@ async function _apiRequest(endpoint, options = {}) { const contentType = response.headers.get("content-type"); return contentType?.includes("application/json") ? await response.json() - : {success: true}; + : { success: true }; } catch (error) { if (!error.status) { // Only handle network errors here, response errors are handled above @@ -70,11 +70,11 @@ function _updateProfile(username, profileData) { const index = profiles.findIndex((p) => p.username === username); if (index !== -1) { - profiles[index] = {...profiles[index], ...profileData}; + profiles[index] = { ...profiles[index], ...profileData }; } else { - profiles.push({username, ...profileData}); + profiles.push({ username, ...profileData }); } - state.updateState({profiles}); + state.updateState({ profiles }); } // ====== AUTH methods @@ -82,7 +82,7 @@ async function login(username, password) { try { const data = await _apiRequest("/login", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -96,7 +96,7 @@ async function login(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -104,12 +104,12 @@ async function getWhoToFollow() { try { const usernamesToFollow = await _apiRequest("/suggested-follows/3"); - state.updateState({whoToFollow: usernamesToFollow}); + state.updateState({ whoToFollow: usernamesToFollow }); return usernamesToFollow; } catch (error) { // Error already handled by _apiRequest - state.updateState({usernamesToFollow: []}); + state.updateState({ usernamesToFollow: [] }); return []; } } @@ -118,7 +118,7 @@ async function signup(username, password) { try { const data = await _apiRequest("/register", { method: "POST", - body: JSON.stringify({username, password}), + body: JSON.stringify({ username, password }), }); if (data.success && data.token) { @@ -132,20 +132,20 @@ async function signup(username, password) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } function logout() { state.destroyState(); - return {success: true}; + return { success: true }; } // ===== BLOOM methods async function getBloom(bloomId) { const endpoint = `/bloom/${bloomId}`; const bloom = await _apiRequest(endpoint); - state.updateState({singleBloomToShow: bloom}); + state.updateState({ singleBloomToShow: bloom }); return bloom; } @@ -156,18 +156,18 @@ async function getBlooms(username) { const blooms = await _apiRequest(endpoint); if (username) { - _updateProfile(username, {blooms}); + _updateProfile(username, { blooms }); } else { - state.updateState({timelineBlooms: blooms}); + state.updateState({ timelineBlooms: blooms }); } return blooms; } catch (error) { // Error already handled by _apiRequest if (username) { - _updateProfile(username, {blooms: []}); + _updateProfile(username, { blooms: [] }); } else { - state.updateState({timelineBlooms: []}); + state.updateState({ timelineBlooms: [] }); } return []; } @@ -189,7 +189,7 @@ async function getBloomsByHashtag(hashtag) { return blooms; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -197,7 +197,7 @@ async function postBloom(content) { try { const data = await _apiRequest("/bloom", { method: "POST", - body: JSON.stringify({content}), + body: JSON.stringify({ content }), }); if (data.success) { @@ -208,7 +208,21 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; + } +} + +async function rebloom(originalBloomId) { + try { + const data = await _apiRequest("/rebloom", { + method: "POST", + body: JSON.stringify({ original_bloom_id: originalBloomId }), + }); + + return data; + } catch (error) { + // Error already handled by _apiRequest + return { success: false }; } } @@ -225,16 +239,16 @@ async function getProfile(username) { const currentUsername = profileData.username; const fullProfileData = await _apiRequest(`/profile/${currentUsername}`); _updateProfile(currentUsername, fullProfileData); - state.updateState({currentUser: currentUsername, isLoggedIn: true}); + state.updateState({ currentUser: currentUsername, isLoggedIn: true }); } return profileData; } catch (error) { // Error already handled by _apiRequest if (!username) { - state.updateState({isLoggedIn: false, currentUser: null}); + state.updateState({ isLoggedIn: false, currentUser: null }); } - return {success: false}; + return { success: false }; } } @@ -242,7 +256,7 @@ async function followUser(username) { try { const data = await _apiRequest("/follow", { method: "POST", - body: JSON.stringify({follow_username: username}), + body: JSON.stringify({ follow_username: username }), }); if (data.success) { @@ -255,7 +269,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +291,7 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -291,6 +305,7 @@ const apiService = { getBloom, getBlooms, postBloom, + rebloom, getBloomsByHashtag, // User methods @@ -300,4 +315,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService }; diff --git a/front-end/views/home.mjs b/front-end/views/home.mjs index 85a5cca..8ad9b02 100644 --- a/front-end/views/home.mjs +++ b/front-end/views/home.mjs @@ -1,4 +1,4 @@ -import {renderEach, renderOne, destroy} from "../lib/render.mjs"; +import { renderEach, renderOne, destroy } from "../lib/render.mjs"; import { state, getLogoutContainer, @@ -7,15 +7,15 @@ import { getTimelineContainer, getBloomFormContainer, } from "../index.mjs"; -import {createLogin, handleLogin} from "../components/login.mjs"; -import {createLogout, handleLogout} from "../components/logout.mjs"; -import {createProfile} from "../components/profile.mjs"; +import { createLogin, handleLogin } from "../components/login.mjs"; +import { createLogout, handleLogout } from "../components/logout.mjs"; +import { createProfile } from "../components/profile.mjs"; import { createBloomForm, handleBloomSubmit, handleTyping, } from "../components/bloom-form.mjs"; -import {createBloom} from "../components/bloom.mjs"; +import { createBloom, handleRebloom } from "../components/bloom.mjs"; // Home view - logged in or not function homeView() { @@ -24,31 +24,36 @@ function homeView() { if (state.isLoggedIn) { renderOne( { - profileData: state.profiles.find((p) => p.username === state.currentUser), + profileData: state.profiles.find( + (p) => p.username === state.currentUser, + ), whoToFollow: state.whoToFollow, isLoggedIn: state.isLoggedIn, }, getProfileContainer(), "profile-template", - createProfile + createProfile, ); renderEach( state.timelineBlooms, getTimelineContainer(), "bloom-template", - createBloom + createBloom, ); + document.querySelectorAll("[data-action='rebloom']").forEach((button) => { + button.addEventListener("click", handleRebloom); + }); renderOne( state.isLoggedIn, getBloomFormContainer(), "bloom-form-template", - createBloomForm + createBloomForm, ); renderOne( state.isLoggedIn, getLogoutContainer(), "logout-template", - createLogout + createLogout, ); document .querySelector("[data-action='logout']") @@ -62,11 +67,11 @@ function homeView() { state.isLoggedIn, getLoginContainer(), "login-template", - createLogin + createLogin, ); document .querySelector("[data-form='login']") ?.addEventListener("submit", handleLogin); } } -export {homeView}; +export { homeView };