diff --git a/backend/data/blooms.py b/backend/data/blooms.py index 7e280cf3..075deb06 100644 --- a/backend/data/blooms.py +++ b/backend/data/blooms.py @@ -13,6 +13,10 @@ class Bloom: sender: User content: str sent_timestamp: datetime.datetime + rebloom_count: int = 0 + rebloomed_by: Optional[str] = None + rebloom_timestamp: Optional[datetime.datetime] = None + rebloomed_by_viewer: bool = False def add_bloom(*, sender: User, content: str) -> Bloom: @@ -36,13 +40,32 @@ def add_bloom(*, sender: User, content: str) -> Bloom: dict(hashtag=hashtag, bloom_id=bloom_id), ) +def add_rebloom(*, rebloomer: User, bloom_id: int) -> None: + now = datetime.datetime.now(tz=datetime.UTC) + with db_cursor() as cur: + cur.execute( + """INSERT INTO reblooms (bloom_id, user_id, rebloom_timestamp) + VALUES (%(bloom_id)s, %(user_id)s, %(timestamp)s) + ON CONFLICT (user_id, bloom_id) DO NOTHING""", + dict(bloom_id=bloom_id, user_id=rebloomer.id, timestamp=now), + ) + + +def remove_rebloom(*, rebloomer: User, bloom_id: int) -> None: + with db_cursor() as cur: + cur.execute( + "DELETE FROM reblooms WHERE bloom_id = %(bloom_id)s AND user_id = %(user_id)s", + dict(bloom_id=bloom_id, user_id=rebloomer.id), + ) + def get_blooms_for_user( - username: str, *, before: Optional[int] = None, limit: Optional[int] = None + username: str, *, before: Optional[int] = None, limit: Optional[int] = None, viewer_username: Optional[str] = None, ) -> List[Bloom]: with db_cursor() as cur: kwargs = { "sender_username": username, + "viewer_username": viewer_username, } if before is not None: before_clause = "AND send_timestamp < %(before_limit)s" @@ -54,13 +77,25 @@ def get_blooms_for_user( cur.execute( f"""SELECT - blooms.id, users.username, content, send_timestamp + b.id, u.username, b.content, b.send_timestamp, + rb_user.username, r.rebloom_timestamp, + (SELECT COUNT(*) FROM reblooms WHERE bloom_id = b.id) AS rebloom_count, + EXISTS( + SELECT 1 FROM reblooms rv + INNER JOIN users vu ON vu.id = rv.user_id + WHERE rv.bloom_id = b.id AND vu.username = %(viewer_username)s + ) AS rebloomed_by_viewer FROM - blooms INNER JOIN users ON users.id = blooms.sender_id + blooms b + INNER JOIN users u ON u.id = b.sender_id + LEFT JOIN reblooms r ON r.bloom_id = b.id AND r.user_id = ( + SELECT id FROM users WHERE username = %(sender_username)s + ) + LEFT JOIN users rb_user ON rb_user.id = r.user_id WHERE - username = %(sender_username)s + (u.username = %(sender_username)s OR r.id IS NOT NULL) {before_clause} - ORDER BY send_timestamp DESC + ORDER BY COALESCE(r.rebloom_timestamp, b.send_timestamp) DESC {limit_clause} """, kwargs, @@ -68,13 +103,20 @@ def get_blooms_for_user( rows = cur.fetchall() blooms = [] for row in rows: - bloom_id, sender_username, content, timestamp = row + (bloom_id, sender_username, content, timestamp, rebloomed_by, + rebloom_timestamp, + rebloom_count, + rebloomed_by_viewer,) = row blooms.append( Bloom( id=bloom_id, sender=sender_username, content=content, sent_timestamp=timestamp, + rebloomed_by=rebloomed_by, + rebloom_timestamp=rebloom_timestamp, + rebloom_count=rebloom_count, + rebloomed_by_viewer=rebloomed_by_viewer, ) ) return blooms diff --git a/backend/endpoints.py b/backend/endpoints.py index 0e177a07..24fbd927 100644 --- a/backend/endpoints.py +++ b/backend/endpoints.py @@ -110,7 +110,10 @@ def other_profile(profile_username): current_user = get_current_user() followers = get_inverse_followed_usernames(profile_user) - all_blooms = blooms.get_blooms_for_user(profile_username) + all_blooms = blooms.get_blooms_for_user( + profile_username, + viewer_username=current_user.username if current_user else None, + ) all_blooms.reverse() return jsonify( { @@ -185,7 +188,7 @@ def home_timeline(): # Get blooms from followed users followed_users = get_followed_usernames(current_user) nested_user_blooms = [ - blooms.get_blooms_for_user(followed_user, limit=50) + blooms.get_blooms_for_user(followed_user, limit=50, viewer_username=current_user.username) for followed_user in followed_users ] @@ -193,19 +196,18 @@ def home_timeline(): followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms] # Get the current user's own blooms - own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50) + own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50, viewer_username=current_user.username) # Combine own blooms with followed blooms all_blooms = followed_blooms + own_blooms # Sort by timestamp (newest first) sorted_blooms = list( - sorted(all_blooms, key=lambda bloom: bloom.sent_timestamp, reverse=True) + sorted(all_blooms, key=lambda bloom: bloom.rebloom_timestamp or bloom.sent_timestamp, reverse=True) ) return jsonify(sorted_blooms) - def user_blooms(profile_username): user_blooms = blooms.get_blooms_for_user(profile_username) user_blooms.reverse() @@ -245,3 +247,25 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No ) ) return None + +@jwt_required() +def do_rebloom(id_str): + try: + bloom_id = int(id_str) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + if blooms.get_bloom(bloom_id) is None: + return make_response((f"Bloom not found", 404)) + blooms.add_rebloom(rebloomer=get_current_user(), bloom_id=bloom_id) + return jsonify({"success": True}) + + +@jwt_required() +def undo_rebloom(id_str): + try: + bloom_id = int(id_str) + except ValueError: + return make_response((f"Invalid bloom id", 400)) + blooms.remove_rebloom(rebloomer=get_current_user(), bloom_id=bloom_id) + return jsonify({"success": True}) + diff --git a/backend/main.py b/backend/main.py index 7ba155fa..163990cc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,8 @@ send_bloom, suggested_follows, user_blooms, + do_rebloom, + undo_rebloom, ) from dotenv import load_dotenv @@ -60,6 +62,8 @@ 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("/bloom//rebloom", methods=["POST"], view_func=do_rebloom) + app.add_url_rule("/bloom//unrebloom", methods=["POST"], view_func=undo_rebloom) app.run(host="0.0.0.0", port="3000", debug=True) diff --git a/db/schema.sql b/db/schema.sql index 61e7580c..81eb04ee 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -CREATE TABLE users ( +CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username VARCHAR NOT NULL, password_salt BYTEA NOT NULL, @@ -6,23 +6,34 @@ CREATE TABLE users ( UNIQUE(username) ); -CREATE TABLE blooms ( +CREATE TABLE IF NOT EXISTS blooms ( id BIGSERIAL NOT NULL PRIMARY KEY, sender_id INT NOT NULL REFERENCES users(id), content TEXT NOT NULL, send_timestamp TIMESTAMP NOT NULL ); -CREATE TABLE follows ( +CREATE TABLE IF NOT EXISTS follows ( id SERIAL PRIMARY KEY, follower INT NOT NULL REFERENCES users(id), followee INT NOT NULL REFERENCES users(id), UNIQUE(follower, followee) ); -CREATE TABLE hashtags ( +CREATE TABLE IF NOT EXISTS hashtags ( id SERIAL PRIMARY KEY, hashtag VARCHAR NOT NULL, bloom_id BIGINT NOT NULL REFERENCES blooms(id), UNIQUE(hashtag, bloom_id) ); + +CREATE TABLE IF NOT EXISTS reblooms ( + id BIGSERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id), + bloom_id BIGINT NOT NULL REFERENCES blooms(id), + rebloom_timestamp TIMESTAMP NOT NULL, + UNIQUE(user_id, bloom_id) +); + +CREATE INDEX IF NOT EXISTS idx_reblooms_bloom_id ON reblooms (bloom_id); +CREATE INDEX IF NOT EXISTS idx_reblooms_user_id ON reblooms (user_id); diff --git a/front-end/components/bloom.mjs b/front-end/components/bloom.mjs index 0b4166c3..8a901d23 100644 --- a/front-end/components/bloom.mjs +++ b/front-end/components/bloom.mjs @@ -1,3 +1,5 @@ +import { apiService } from "../lib/api.mjs"; + /** * Create a bloom component * @param {string} template - The ID of the template to clone @@ -20,17 +22,64 @@ 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 rebloomBanner = bloomFrag.querySelector("[data-rebloom-banner]"); + const rebloomUsername = bloomFrag.querySelector("[data-rebloom-username]"); + const rebloomButton = bloomFrag.querySelector('[data-action="rebloom"]'); + const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]"); + + const displayTimestamp = bloom.rebloomed_by + ? bloom.rebloom_timestamp + : bloom.sent_timestamp; bloomArticle.setAttribute("data-bloom-id", bloom.id); bloomUsername.setAttribute("href", `/profile/${bloom.sender}`); bloomUsername.textContent = bloom.sender; - bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp); + bloomTime.textContent = _formatTimestamp(displayTimestamp); bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`); bloomContent.replaceChildren( ...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html") - .body.childNodes + .body.childNodes, ); + + if (bloom.rebloomed_by) { + bloomArticle.setAttribute("data-is-rebloom", "true"); + if (rebloomBanner && rebloomUsername) { + rebloomUsername.setAttribute("href", `/profile/${bloom.rebloomed_by}`); + rebloomUsername.textContent = bloom.rebloomed_by; + rebloomBanner.hidden = false; + } + } + + if (rebloomCount) { + if (bloom.rebloom_count > 0) { + rebloomCount.textContent = bloom.rebloom_count; + rebloomCount.hidden = false; + } else { + rebloomCount.hidden = true; + } + } + + if (rebloomButton) { + let isRebloomedByViewer = Boolean(bloom.rebloomed_by_viewer); + rebloomButton.setAttribute("data-active", String(isRebloomedByViewer)); + rebloomButton.setAttribute("aria-pressed", String(isRebloomedByViewer)); + + rebloomButton.addEventListener("click", async () => { + rebloomButton.disabled = true; + const action = isRebloomedByViewer + ? apiService.unrebloom + : apiService.rebloom; + const result = await action(bloom.id); + if (result.success) { + isRebloomedByViewer = !isRebloomedByViewer; + rebloomButton.setAttribute("data-active", String(isRebloomedByViewer)); + rebloomButton.setAttribute("aria-pressed", String(isRebloomedByViewer)); + } + rebloomButton.disabled = false; + }); + } + return bloomFrag; }; @@ -38,7 +87,7 @@ function _formatHashtags(text) { if (!text) return text; return text.replace( /\B#[^#]+/g, - (match) => `${match}` + (match) => `${match}`, ); } @@ -84,4 +133,4 @@ function _formatTimestamp(timestamp) { } } -export {createBloom}; +export { createBloom }; diff --git a/front-end/index.html b/front-end/index.html index 89d6b130..04e40b8c 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,27 @@

    Share a Bloom

    diff --git a/front-end/lib/api.mjs b/front-end/lib/api.mjs index f4b5339b..0289037d 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,7 @@ async function postBloom(content) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; } } @@ -225,16 +225,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 +242,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 +255,7 @@ async function followUser(username) { return data; } catch (error) { - return {success: false}; + return { success: false }; } } @@ -277,7 +277,31 @@ async function unfollowUser(username) { return data; } catch (error) { // Error already handled by _apiRequest - return {success: false}; + return { success: false }; + } +} + +async function rebloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${bloomId}/rebloom`, { + method: "POST", + }); + if (data.success) await getBlooms(); + return data; + } catch (error) { + return { success: false }; + } +} + +async function unrebloom(bloomId) { + try { + const data = await _apiRequest(`/bloom/${bloomId}/unrebloom`, { + method: "POST", + }); + if (data.success) await getBlooms(); + return data; + } catch (error) { + return { success: false }; } } @@ -292,6 +316,8 @@ const apiService = { getBlooms, postBloom, getBloomsByHashtag, + rebloom, + unrebloom, // User methods getProfile, @@ -300,4 +326,4 @@ const apiService = { getWhoToFollow, }; -export {apiService}; +export { apiService };