Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand All @@ -54,27 +77,46 @@ 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,
)
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
Expand Down
34 changes: 29 additions & 5 deletions backend/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -185,27 +188,26 @@ 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
]

# Flatten list of blooms from followed users
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()
Expand Down Expand Up @@ -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})

4 changes: 4 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
send_bloom,
suggested_follows,
user_blooms,
do_rebloom,
undo_rebloom,
)

from dotenv import load_dotenv
Expand Down Expand Up @@ -60,6 +62,8 @@ def main():
app.add_url_rule("/bloom/<id_str>", methods=["GET"], view_func=get_bloom)
app.add_url_rule("/blooms/<profile_username>", view_func=user_blooms)
app.add_url_rule("/hashtag/<hashtag>", view_func=hashtag)
app.add_url_rule("/bloom/<id_str>/rebloom", methods=["POST"], view_func=do_rebloom)
app.add_url_rule("/bloom/<id_str>/unrebloom", methods=["POST"], view_func=undo_rebloom)

app.run(host="0.0.0.0", port="3000", debug=True)

Expand Down
19 changes: 15 additions & 4 deletions db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
CREATE TABLE users (
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL,
password_salt BYTEA NOT NULL,
password_scrypt BYTEA NOT NULL,
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);
57 changes: 53 additions & 4 deletions front-end/components/bloom.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { apiService } from "../lib/api.mjs";

/**
* Create a bloom component
* @param {string} template - The ID of the template to clone
Expand All @@ -20,25 +22,72 @@ 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;
};

function _formatHashtags(text) {
if (!text) return text;
return text.replace(
/\B#[^#]+/g,
(match) => `<a href="/hashtag/${match.slice(1)}">${match}</a>`
(match) => `<a href="/hashtag/${match.slice(1)}">${match}</a>`,
);
}

Expand Down Expand Up @@ -84,4 +133,4 @@ function _formatTimestamp(timestamp) {
}
}

export {createBloom};
export { createBloom };
30 changes: 20 additions & 10 deletions front-end/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
Expand All @@ -10,12 +10,7 @@
<header>
<a href="/"
><h1>
<img
src="/logo.svg"
alt="Purple Forest "
width="50"
height="60"
/>
<img src="/logo.svg" alt="Purple Forest " width="50" height="60" />
PurpleForest
</h1></a
>
Expand Down Expand Up @@ -189,8 +184,7 @@ <h1 id="signup-heading" class="signup__title">Create your account</h1>
</div>
<div class="profile__who-to-follow">
<h4>Who to follow</h4>
<ul data-who-to-follow>
</ul>
<ul data-who-to-follow></ul>
</div>
</section>
</template>
Expand Down Expand Up @@ -234,11 +228,27 @@ <h2 id="bloom-form-title" class="bloom-form__title">Share a Bloom</h2>
<!-- Bloom Template -->
<template id="bloom-template">
<article class="bloom box" data-bloom data-bloom-id="">
<p class="bloom__rebloom-banner" data-rebloom-banner hidden>
🔁 Rebloomed by <a data-rebloom-username href="#"></a>
</p>
<div class="bloom__header flex">
<a href="#" class="bloom__username" data-username>Username</a>
<a href="#" class="bloom__time"><time class="bloom__time" data-time>2m</time></a>
<a href="#" class="bloom__time"
><time class="bloom__time" data-time>2m</time></a
>
</div>
<div class="bloom__content" data-content></div>
<div class="bloom__actions flex">
<button
type="button"
data-action="rebloom"
data-active="false"
aria-pressed="false"
class="bloom__rebloom-button"
>
🔁 <span data-rebloom-count hidden>0</span>
</button>
</div>
</article>
</template>

Expand Down
Loading