Skip to content
72 changes: 59 additions & 13 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ class Bloom:
sender: User
content: str
sent_timestamp: datetime.datetime
type:str= "bloom"
original_bloom_id:Optional[int] = None
rebloomed_by: Optional[User] = None



def add_bloom(*, sender: User, content: str) -> Bloom:
if len(content) > 280:
raise ValueError("Bloom content must not exceed 280 character limit.")
hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")]

now = datetime.datetime.now(tz=datetime.UTC)
Expand All @@ -37,13 +43,13 @@ def add_bloom(*, sender: User, content: str) -> Bloom:
)



def get_blooms_for_user(
username: str, *, before: Optional[int] = None, limit: Optional[int] = None
) -> List[Bloom]:
with db_cursor() as cur:
kwargs = {
"sender_username": username,
}
kwargs = {"sender_username": username}

if before is not None:
before_clause = "AND send_timestamp < %(before_limit)s"
kwargs["before_limit"] = before
Expand All @@ -53,48 +59,59 @@ def get_blooms_for_user(
limit_clause = make_limit_clause(limit, kwargs)

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
{before_clause}
f"""
SELECT
blooms.id,
users.username,
blooms.content,
blooms.send_timestamp,
blooms.type,
blooms.original_bloom_id
FROM blooms
INNER JOIN users ON users.id = blooms.sender_id
WHERE username = %(sender_username)s
{before_clause}
ORDER BY 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, type_, original_id = row
blooms.append(
Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
type=type_,
original_bloom_id=original_id,
)
)
return blooms



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 blooms.id, users.username, content, send_timestamp, type, original_bloom_id FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s",
(bloom_id,),
)
row = cur.fetchone()
if row is None:
return None
bloom_id, sender_username, content, timestamp = row
bloom_id, sender_username, content, timestamp, type_, original_id = row
return Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
type=type_,
original_bloom_id=original_id,
)


Expand Down Expand Up @@ -140,3 +157,32 @@ def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str:
else:
limit_clause = ""
return limit_clause

def add_rebloom(*, sender:User, original_bloom_id: int)-> Bloom:
now = datetime.datetime.now(tz=datetime.UTC)
rebloom_id= int(now.timestamp()* 1000000)
with db_cursor() as cur:
# Insert rebloom
cur.execute(
"""INSERT INTO blooms (id, sender_id, content, send_timestamp, type, original_bloom_id)
VALUES (%(rebloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, 'rebloom', %(original_bloom_id)s)""",
dict(
rebloom_id=rebloom_id,
sender_id=sender.id,
content="", # rebloom doesn’t need its own text
timestamp=now,
original_bloom_id=original_bloom_id,
),
)
return get_bloom(rebloom_id)

def bloom_to_dict(bloom: Bloom) -> Dict[str, Any]:
return {
"id": bloom.id,
"sender": bloom.sender if isinstance(bloom.sender, str) else bloom.sender.username,
"content": bloom.content,
"sent_timestamp": bloom.sent_timestamp.isoformat(),
"type": bloom.type,
"original_bloom_id": bloom.original_bloom_id,
}

24 changes: 23 additions & 1 deletion backend/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def get_bloom(id_str):
bloom = blooms.get_bloom(id_int)
if bloom is None:
return make_response((f"Bloom not found", 404))
return jsonify(bloom)
return jsonify(blooms.bloom_to_dict(bloom))


@jwt_required()
Expand Down Expand Up @@ -245,3 +245,25 @@ def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, No
)
)
return None

@jwt_required()
def rebloom(id_str):
try:
original_id = int(id_str)
except ValueError:
return make_response(("Invalid bloom id", 400))

user = get_current_user()

# Check original bloom exists
original = blooms.get_bloom(original_id)
if original is None:
return make_response(("Original bloom not found", 404))

new_rebloom = blooms.add_rebloom(sender=user, original_bloom_id=original_id)

return jsonify({
"success": True,
"rebloom_id": new_rebloom.id,
"original_bloom_id": original_id
})
3 changes: 3 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
send_bloom,
suggested_follows,
user_blooms,
rebloom,
)

from dotenv import load_dotenv
Expand Down Expand Up @@ -59,6 +60,8 @@ def main():
app.add_url_rule("/bloom", methods=["POST"], view_func=send_bloom)
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("/rebloom/<id_str>", methods=["POST"], view_func=rebloom)

app.add_url_rule("/hashtag/<hashtag>", view_func=hashtag)

app.run(host="0.0.0.0", port="3000", debug=True)
Expand Down
9 changes: 7 additions & 2 deletions front-end/components/bloom-form.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {apiService} from "../index.mjs";
import { apiService } from "../index.mjs";

/**
* Create a bloom form component
Expand Down Expand Up @@ -26,6 +26,11 @@ async function handleBloomSubmit(event) {
const originalText = submitButton.textContent;
const textarea = form.querySelector("textarea");
const content = textarea.value.trim();
const charMaxLength = 280;
if (content.length > charMaxLength) {
alert(`Bloom content must be 280 characters or less.`);
return;
}

try {
// Make form inert while we call the back end
Expand Down Expand Up @@ -55,4 +60,4 @@ function handleTyping(event) {
counter.textContent = `${textarea.value.length} / ${maxLength}`;
}

export {createBloomForm, handleBloomSubmit, handleTyping};
export { createBloomForm, handleBloomSubmit, handleTyping };
72 changes: 65 additions & 7 deletions front-end/components/bloom.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { apiService } from "../lib/api.mjs";
/**
* Create a bloom component
* @param {string} template - The ID of the template to clone
Expand Down Expand Up @@ -26,19 +27,76 @@ const createBloom = (template, bloom) => {
bloomUsername.textContent = bloom.sender;
bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp);
bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`);
bloomContent.replaceChildren(
...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html")
.body.childNodes
);

if (bloom.type === "rebloom" && bloom.original_bloom_id) {
if (!window._bloomCache) window._bloomCache = {};

const cached = window._bloomCache[bloom.original_bloom_id];
if (cached) {
bloomContent.innerHTML = `
Reblooomed <strong>@${cached.sender}</strong><br>
<em>${cached.content}</em>
`;
} else {
apiService
.getBloom(bloom.original_bloom_id)
.then((original) => {
window._bloomCache[bloom.original_bloom_id] = original;
bloomContent.innerHTML = `
Reblooomed <strong>@${original.sender}</strong><br>
<em>${original.content}</em>
`;
})
.catch(() => {
bloomContent.textContent = " Rebloom (original not found)";
});
}
} else {
bloomContent.replaceChildren(
...bloomParser.parseFromString(
_formatHashtags(bloom.content),
"text/html"
).body.childNodes
);
}

// --- Add a Rebloom button ---
const rebloomButton = document.createElement("button");
rebloomButton.textContent = " Rebloom";
rebloomButton.classList.add("rebloom-button");
rebloomButton.addEventListener("click", async () => {
try {
rebloomButton.disabled = true;
rebloomButton.textContent = "Reblooming...";
const result = await apiService.rebloomBloom(bloom.id);

if (result.success) {
rebloomButton.textContent = "Rebloomed!";
} else {
alert("Rebloom failed");
rebloomButton.textContent = " Rebloom";
}
} catch (err) {
console.error(err);
alert("Failed to rebloom.");
rebloomButton.textContent = " Rebloom";
} finally {
rebloomButton.disabled = false;
}
});

// Add the button to the bloom card (e.g., at the bottom)
bloomArticle.appendChild(rebloomButton);

return bloomFrag;
};

function _formatHashtags(text) {
if (!text) return text;
// special character in hashtag convert into url friendly format
return text.replace(
/\B#[^#]+/g,
(match) => `<a href="/hashtag/${match.slice(1)}">${match}</a>`
/\B#(\w+)/g,
(match, tag) => `<a href="/hashtag/${encodeURIComponent(tag)}">${match}</a>`
);
}

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

export {createBloom};
export { createBloom };
29 changes: 29 additions & 0 deletions front-end/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,35 @@ dialog {
color: var(--error);
}

/* REBLOOM BUTTON */
.rebloom-button {
font: 600 90% monospace, system-ui;
background-color: var(--paper);
color: var(--brand);
border: 0.5px solid var(--brand);
border-radius: var(--pill);
box-shadow: 2px 3px var(--brand);
padding: calc(var(--space) / 4) calc(var(--space));
cursor: pointer;
transition: all 0.3s ease;
margin-top: calc(var(--space) / 3);
align-self: start;
}

.rebloom-button:hover,
.rebloom-button:focus {
background-color: var(--brand);
color: var(--paper);
box-shadow: 0 0 var(--accent);
border-color: var(--brand);
}

.rebloom-button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}

@keyframes fade {
from {
opacity: 0%;
Expand Down
Loading