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
126 changes: 119 additions & 7 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down
76 changes: 61 additions & 15 deletions backend/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
home_timeline,
login,
other_profile,
rebloom,
register,
self_profile,
send_bloom,
Expand Down Expand Up @@ -57,6 +58,7 @@ def main():
app.add_url_rule("/suggested-follows/<limit_str>", 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/<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)
Expand Down
8 changes: 8 additions & 0 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
82 changes: 72 additions & 10 deletions front-end/components/bloom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `<a href="/hashtag/${match.slice(1)}">${match}</a>`
(match) => `<a href="/hashtag/${match.slice(1)}">${match}</a>`,
);
}

Expand Down Expand Up @@ -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 };
Loading