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
3 changes: 2 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ To run:
3. Activate the virtual environment: `. .venv/bin/activate`
4. Install dependencies: `pip install -r requirements.txt`
5. Run the database: `../db/run.sh` (you must have Docker installed and running).
6. Create the database schema: `../db/create-schema.sh`
6.run the server python3 main.py
7. Create the database schema: `../db/create-schema.sh`

You may want to run `python3 populate.py` to populate sample data.

Expand Down
61 changes: 45 additions & 16 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,33 @@ class Bloom:
sender: User
content: str
sent_timestamp: datetime.datetime
original_bloom_id: Optional[int] =None
original_sender: Optional[str] = None
rebloom_count: int = 0


def add_bloom(*, sender: User, content: str) -> Bloom:
def add_bloom(*, sender: User, content: str, original_bloom_id: Optional[int] = None) -> Bloom:
hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")]

now = datetime.datetime.now(tz=datetime.UTC)
bloom_id = int(now.timestamp() * 1000000)
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)",
"INSERT INTO blooms (id, sender_id, content, send_timestamp, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(original_bloom_id)s)",
dict(
bloom_id=bloom_id,
sender_id=sender.id,
content=content,
timestamp=datetime.datetime.now(datetime.UTC),
original_bloom_id=original_bloom_id,
),
)
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),
)
return Bloom(id=bloom_id, sender=sender.username, content=content, sent_timestamp=now, original_bloom_id=original_bloom_id)


def get_blooms_for_user(
Expand All @@ -51,30 +56,40 @@ def get_blooms_for_user(
before_clause = ""

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
b.id,
u.username,
b.content,
b.send_timestamp,
b.original_bloom_id,
ou.username AS original_sender,
(SELECT COUNT(*) FROM blooms WHERE original_bloom_id = COALESCE(b.original_bloom_id, b.id)) AS rebloom_count
FROM blooms b
INNER JOIN users u ON u.id = b.sender_id
LEFT JOIN blooms ob ON b.original_bloom_id = ob.id
LEFT JOIN users ou ON ob.sender_id = ou.id
WHERE
username = %(sender_username)s
u.username = %(sender_username)s
{before_clause}
ORDER BY send_timestamp DESC
ORDER BY 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, original_bloom_id, original_sender, rebloom_count = row
blooms.append(
Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
original_bloom_id=original_bloom_id,
original_sender=original_sender,
rebloom_count=rebloom_count,
)
)
return blooms
Expand All @@ -83,18 +98,21 @@ def get_blooms_for_user(
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 b.id, u.username, b.content, b.send_timestamp, b.original_bloom_id, ou.username AS original_sender, (SELECT COUNT(*) FROM blooms WHERE original_bloom_id = COALESCE(b.original_bloom_id, b.id)) AS rebloom_count FROM blooms b INNER JOIN users u ON u.id = b.sender_id LEFT JOIN blooms ob ON b.original_bloom_id = ob.id LEFT JOIN users ou ON ob.sender_id = ou.id WHERE b.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, original_bloom_id, original_sender, rebloom_count = row
return Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
original_bloom_id=original_bloom_id,
original_sender=original_sender,
rebloom_count=rebloom_count,
)


Expand All @@ -108,26 +126,37 @@ def get_blooms_with_hashtag(
with db_cursor() as cur:
cur.execute(
f"""SELECT
blooms.id, users.username, content, send_timestamp
FROM
blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id
b.id,
u.username,
b.content,
b.send_timestamp,
b.original_bloom_id,
ou.username AS original_sender,
(SELECT COUNT(*) FROM blooms WHERE original_bloom_id = COALESCE(b.original_bloom_id, b.id)) AS rebloom_count
FROM blooms b
INNER JOIN hashtags ON b.id = hashtags.bloom_id INNER JOIN users u ON b.sender_id = u.id
LEFT JOIN blooms ob ON b.original_bloom_id = ob.id
LEFT JOIN users ou ON ob.sender_id = ou.id
WHERE
hashtag = %(hashtag_without_leading_hash)s
ORDER BY send_timestamp DESC
ORDER BY 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, original_bloom_id, original_sender, rebloom_count = row
blooms.append(
Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
original_bloom_id=original_bloom_id,
original_sender=original_sender,
rebloom_count=rebloom_count,
)
)
return blooms
Expand Down
19 changes: 19 additions & 0 deletions backend/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ def get_bloom(id_str):
return jsonify(bloom)


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

original = blooms.get_bloom(id_int)
if original is None:
return make_response((f"Bloom not found", 404))

user = get_current_user()

# Create a new bloom with the same content, referencing the original bloom
blooms.add_bloom(sender=user, content=original.content, original_bloom_id=id_int)

return jsonify({"success": True})


@jwt_required()
def home_timeline():
current_user = get_current_user()
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
register,
self_profile,
send_bloom,
do_rebloom,
suggested_follows,
user_blooms,
)
Expand Down Expand Up @@ -58,6 +59,7 @@ 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("/bloom/<id_str>/rebloom", methods=["POST"], view_func=do_rebloom)
app.add_url_rule("/blooms/<profile_username>", view_func=user_blooms)
app.add_url_rule("/hashtag/<hashtag>", view_func=hashtag)

Expand Down
2 changes: 1 addition & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.5
MarkupSafe==3.0.2
psycopg2==2.9.10
psycopg2-binary==2.9.10
pycparser==2.22
PyJWT==2.10.1
python-dotenv==1.0.1
Expand Down
3 changes: 2 additions & 1 deletion db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ CREATE TABLE blooms (
id BIGSERIAL NOT NULL PRIMARY KEY,
sender_id INT NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
send_timestamp TIMESTAMP NOT NULL
send_timestamp TIMESTAMP NOT NULL,
original_bloom_id BIGINT REFERENCES blooms(id)
);

CREATE TABLE follows (
Expand Down
35 changes: 32 additions & 3 deletions front-end/components/bloom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,45 @@ const createBloom = (template, bloom) => {
const bloomTimeLink = bloomFrag.querySelector("a:has(> [data-time])");
const bloomContent = bloomFrag.querySelector("[data-content]");


const rebloomInfo = bloomFrag.querySelector("[data-rebloom-info]");
const rebloomSender = bloomFrag.querySelector("[data-rebloom-sender]");
const rebloomCount = bloomFrag.querySelector("[data-rebloom-count]");

bloomArticle.setAttribute("data-bloom-id", bloom.id);
bloomUsername.setAttribute("href", `/profile/${bloom.sender}`);
bloomUsername.textContent = bloom.sender;


if (bloom.original_sender) {

bloomUsername.setAttribute("href", `/profile/${bloom.original_sender}`);
bloomUsername.textContent = bloom.original_sender;


if (rebloomInfo && rebloomSender) {
rebloomInfo.hidden = false;
rebloomSender.setAttribute("href", `/profile/${bloom.sender}`);
rebloomSender.textContent = bloom.sender;
}
} else {
bloomUsername.setAttribute("href", `/profile/${bloom.sender}`);
bloomUsername.textContent = bloom.sender;
if (rebloomInfo) rebloomInfo.hidden = true;
}

bloomTime.textContent = _formatTimestamp(bloom.sent_timestamp);
bloomTimeLink.setAttribute("href", `/bloom/${bloom.id}`);

const timeLink = bloomTime ? bloomTime.closest("a") : null;
if (timeLink) timeLink.setAttribute("href", `/bloom/${bloom.id}`);
bloomContent.replaceChildren(
...bloomParser.parseFromString(_formatHashtags(bloom.content), "text/html")
.body.childNodes
);


if (rebloomCount) {
rebloomCount.textContent = String(bloom.rebloom_count ?? 0);
}

return bloomFrag;
};

Expand Down
18 changes: 17 additions & 1 deletion front-end/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,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="">
<div class="bloom__meta system-message" data-rebloom-info hidden>
<small> Re-bloomed by <a href="#" data-rebloom-sender></a></small>
</div>
<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>
<footer class="bloom__footer">
<button
type="button"
data-action="rebloom"
class="bloom__rebloom"
aria-label="Re-bloom this post"
title="Re-bloom"
>
Re-bloom (<span data-rebloom-count>0</span>)
</button>
</footer>
</article>
</template>

Expand Down
19 changes: 19 additions & 0 deletions front-end/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ async function init() {
document.addEventListener("state-change", () => {
handleRouteChange();
});


document.addEventListener("click", async (event) => {
const btn = event.target.closest && event.target.closest('[data-action="rebloom"]');
if (!btn) return;

const article = btn.closest && btn.closest('[data-bloom]');
if (!article) return;

const bloomId = article.getAttribute('data-bloom-id');
if (!bloomId) return;

try {
await apiService.postRebloom(bloomId);
} catch (err) {
// errors are already handled/displayed by apiService
console.error('Rebloom failed', err);
}
});
}

// TODO Check any unhandled errors bubble up to this central handler
Expand Down
19 changes: 19 additions & 0 deletions front-end/lib/api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,24 @@ async function postBloom(content) {
}
}

async function postRebloom(bloomId) {
try {
const data = await _apiRequest(`/bloom/${bloomId}/rebloom`, {
method: "POST",
});

if (data.success) {
// Refresh timeline and current user's profile
await Promise.all([getBlooms(), getProfile(state.currentUser)]);
}

return data;
} catch (error) {
// Error already handled by _apiRequest
return {success: false};
}
}

// ======= USER methods
async function getProfile(username) {
const endpoint = username ? `/profile/${username}` : "/profile";
Expand Down Expand Up @@ -291,6 +309,7 @@ const apiService = {
getBloom,
getBlooms,
postBloom,
postRebloom,
getBloomsByHashtag,

// User methods
Expand Down