From a5c71fd2f85641f5dd81933d18e9f22ce56cf474 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 25 May 2026 16:16:53 +0200 Subject: [PATCH] Update --- devplacepy/database.py | 26 +++ devplacepy/routers/comments.py | 34 +--- devplacepy/routers/follow.py | 2 +- devplacepy/routers/messages.py | 1 + devplacepy/routers/notifications.py | 13 ++ devplacepy/routers/votes.py | 5 +- devplacepy/static/css/notifications.css | 1 + devplacepy/static/css/post.css | 16 ++ devplacepy/static/js/Application.js | 2 + devplacepy/static/js/NotificationManager.js | 51 ++++++ devplacepy/static/js/VoteManager.js | 13 -- devplacepy/templates/_comment_section.html | 2 +- devplacepy/templates/notifications.html | 5 +- devplacepy/utils.py | 6 +- tests/test_notifications.py | 179 ++++++++++++++++++++ 15 files changed, 309 insertions(+), 47 deletions(-) create mode 100644 devplacepy/static/js/NotificationManager.js diff --git a/devplacepy/database.py b/devplacepy/database.py index 3434e7b..9b33327 100644 --- a/devplacepy/database.py +++ b/devplacepy/database.py @@ -385,6 +385,32 @@ def resolve_by_slug(table, slug): return entry +def resolve_object_url(target_type: str, target_uid: str) -> str: + if target_type == "post": + post = resolve_by_slug(get_table("posts"), target_uid) + return f"/posts/{post['slug'] or post['uid']}" if post else "/feed" + if target_type == "project": + project = resolve_by_slug(get_table("projects"), target_uid) + return f"/projects/{project['slug'] or project['uid']}" if project else "/projects" + if target_type == "news": + article = resolve_by_slug(get_table("news"), target_uid) + if article: + return f"/news/{article.get('slug', '') or article['uid']}" + return "/news" + if target_type == "bug": + return f"/bugs?highlight={target_uid}" + if target_type == "gist": + gist = resolve_by_slug(get_table("gists"), target_uid) + return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists" + if target_type == "comment": + comment = get_table("comments").find_one(uid=target_uid) + if not comment: + return "/feed" + parent_url = resolve_object_url(comment.get("target_type", "post"), comment.get("target_uid") or comment.get("post_uid", "")) + return f"{parent_url}#comment-{target_uid}" + return "/feed" + + VOTABLE_TARGETS: dict[str, str] = { "post": "posts", "project": "projects", diff --git a/devplacepy/routers/comments.py b/devplacepy/routers/comments.py index a927f9a..a06609d 100644 --- a/devplacepy/routers/comments.py +++ b/devplacepy/routers/comments.py @@ -3,7 +3,7 @@ from typing import Annotated from datetime import datetime, timezone from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse -from devplacepy.database import get_table, resolve_by_slug +from devplacepy.database import get_table, resolve_object_url from devplacepy.attachments import link_attachments, delete_target_attachments from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge from devplacepy.models import CommentForm @@ -12,26 +12,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def resolve_target_redirect(target_type, target_uid): - if target_type == "post": - post = resolve_by_slug(get_table("posts"), target_uid) - return f"/posts/{post['slug'] or post['uid']}" if post else "/feed" - if target_type == "project": - project = resolve_by_slug(get_table("projects"), target_uid) - return f"/projects/{project['slug'] or project['uid']}" if project else "/projects" - if target_type == "news": - article = resolve_by_slug(get_table("news"), target_uid) - if article: - return f"/news/{article.get('slug', '') or article['uid']}" - return "/news" - if target_type == "bug": - return f"/bugs?highlight={target_uid}" - if target_type == "gist": - gist = resolve_by_slug(get_table("gists"), target_uid) - return f"/gists/{gist['slug'] or gist['uid']}" if gist else "/gists" - return "/bugs" - - @router.post("/create") async def create_comment(request: Request, data: Annotated[CommentForm, Form()]): user = require_user(request) @@ -40,7 +20,7 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()]) target_type = data.target_type parent_uid = data.parent_uid - redirect_url = resolve_target_redirect(target_type, target_uid) + redirect_url = resolve_object_url(target_type, target_uid) comment_uid = generate_uid() insert = { @@ -61,20 +41,22 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()]) award_badge(user["uid"], "First Comment") + comment_url = f"{redirect_url}#comment-{comment_uid}" + if target_type == "post": if parent_uid: parent = get_table("comments").find_one(uid=parent_uid) if parent and parent["user_uid"] != user["uid"]: - create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"]) + create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"], comment_url) else: posts = get_table("posts") post = posts.find_one(uid=target_uid) if not post: post = posts.find_one(slug=target_uid) if post and post["user_uid"] != user["uid"]: - create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"]) + create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"], comment_url) - create_mention_notifications(content, user["uid"], redirect_url) + create_mention_notifications(content, user["uid"], comment_url) logger.info(f"Comment by {user['username']} on {target_type} {target_uid}") return RedirectResponse(url=redirect_url, status_code=302) @@ -94,5 +76,5 @@ async def delete_comment(request: Request, comment_uid: str): get_table("votes").delete(target_uid=comment_uid, target_type="comment") comments.delete(id=comment["id"]) logger.info(f"Comment {comment_uid} deleted by {user['username']}") - redirect_url = resolve_target_redirect(target_type, target_uid) + redirect_url = resolve_object_url(target_type, target_uid) return RedirectResponse(url=redirect_url, status_code=302) diff --git a/devplacepy/routers/follow.py b/devplacepy/routers/follow.py index b6f1958..9eac4d8 100644 --- a/devplacepy/routers/follow.py +++ b/devplacepy/routers/follow.py @@ -29,7 +29,7 @@ async def follow_user(request: Request, username: str): "created_at": datetime.now(timezone.utc).isoformat(), }) - create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"]) + create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"], f"/profile/{user['username']}") logger.info(f"{user['username']} followed {username}") return RedirectResponse(url=f"/profile/{username}", status_code=302) diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index 035db60..611b654 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -174,6 +174,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]): "type": "message", "message": f"{user['username']} sent you a message", "related_uid": user["uid"], + "target_url": f"/messages?with_uid={user['uid']}", "read": False, "created_at": datetime.now(timezone.utc).isoformat(), }) diff --git a/devplacepy/routers/notifications.py b/devplacepy/routers/notifications.py index fda94ba..05f8785 100644 --- a/devplacepy/routers/notifications.py +++ b/devplacepy/routers/notifications.py @@ -86,6 +86,19 @@ async def notifications_page(request: Request): }) +@router.get("/open/{notification_uid}") +async def open_notification(request: Request, notification_uid: str): + user = require_user(request) + notifications_table = get_table("notifications") + n = notifications_table.find_one(uid=notification_uid) + if not n or n["user_uid"] != user["uid"]: + return RedirectResponse(url="/notifications", status_code=302) + if not n["read"]: + notifications_table.update({"id": n["id"], "read": True}, ["id"]) + clear_unread_cache(user["uid"]) + return RedirectResponse(url=n.get("target_url") or "/notifications", status_code=302) + + @router.post("/mark-read/{notification_uid}") async def mark_read(request: Request, notification_uid: str): user = require_user(request) diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index 35215ca..3304b21 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -3,7 +3,7 @@ from typing import Annotated from datetime import datetime, timezone from fastapi import APIRouter, Request, Form from fastapi.responses import RedirectResponse -from devplacepy.database import get_table, update_target_stars, get_target_owner_uid +from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url from devplacepy.utils import generate_uid, require_user, create_notification from devplacepy.models import VoteForm @@ -45,7 +45,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota if value == 1 and target_type in NOTIFY_ON_VOTE: owner_uid = get_target_owner_uid(target_type, target_uid) if owner_uid and owner_uid != user["uid"]: - create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"]) + target_url = resolve_object_url(target_type, target_uid) + create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url) referer = request.headers.get("Referer", "/feed") return RedirectResponse(url=referer, status_code=302) diff --git a/devplacepy/static/css/notifications.css b/devplacepy/static/css/notifications.css index 4fad439..0c1f048 100644 --- a/devplacepy/static/css/notifications.css +++ b/devplacepy/static/css/notifications.css @@ -31,6 +31,7 @@ border-radius: var(--radius-lg); padding: 1.25rem; transition: all 0.2s; + cursor: pointer; } .notification-card.unread { diff --git a/devplacepy/static/css/post.css b/devplacepy/static/css/post.css index fd9d476..a6fef89 100644 --- a/devplacepy/static/css/post.css +++ b/devplacepy/static/css/post.css @@ -115,6 +115,22 @@ min-width: 0; } +.comment-highlight { + animation: comment-highlight-fade 2s ease-out; + border-radius: var(--radius); +} + +@keyframes comment-highlight-fade { + from { + background: var(--accent-light); + box-shadow: 0 0 0 4px var(--accent-light); + } + to { + background: transparent; + box-shadow: 0 0 0 4px transparent; + } +} + .comment-header { display: flex; align-items: center; diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index eda057d..5252013 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -1,6 +1,7 @@ import { ModalManager } from "./ModalManager.js"; import { FormManager } from "./FormManager.js"; import { VoteManager } from "./VoteManager.js"; +import { NotificationManager } from "./NotificationManager.js"; import { MessageSearch } from "./MessageSearch.js"; import { ProfileEditor } from "./ProfileEditor.js"; import { MobileNav } from "./MobileNav.js"; @@ -15,6 +16,7 @@ class Application { this.modals = new ModalManager(); this.forms = new FormManager(); this.votes = new VoteManager(); + this.notifications = new NotificationManager(); this.messageSearch = new MessageSearch(); this.profile = new ProfileEditor(); this.mobileNav = new MobileNav(); diff --git a/devplacepy/static/js/NotificationManager.js b/devplacepy/static/js/NotificationManager.js new file mode 100644 index 0000000..2d95c69 --- /dev/null +++ b/devplacepy/static/js/NotificationManager.js @@ -0,0 +1,51 @@ +import { Http } from "./Http.js"; + +export class NotificationManager { + constructor() { + this.initCardNavigation(); + this.initDismiss(); + this.initHashScroll(); + } + + initCardNavigation() { + document.querySelectorAll(".notification-card[data-href]").forEach((card) => { + card.addEventListener("click", (e) => { + if (e.target.closest("a, button")) { + return; + } + window.location.href = card.dataset.href; + }); + }); + } + + initDismiss() { + document.querySelectorAll(".notification-dismiss").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + const uid = btn.dataset.uid; + if (!uid) { + return; + } + Http.postForm(`/notifications/mark-read/${uid}`); + }); + }); + } + + initHashScroll() { + const hash = window.location.hash; + if (!hash.startsWith("#comment-")) { + return; + } + window.addEventListener("load", () => { + const target = document.getElementById(hash.slice(1)); + if (!target) { + return; + } + requestAnimationFrame(() => { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + target.classList.add("comment-highlight"); + setTimeout(() => target.classList.remove("comment-highlight"), 2000); + }); + }); + } +} diff --git a/devplacepy/static/js/VoteManager.js b/devplacepy/static/js/VoteManager.js index ac6de79..08d9cf6 100644 --- a/devplacepy/static/js/VoteManager.js +++ b/devplacepy/static/js/VoteManager.js @@ -3,7 +3,6 @@ import { Http } from "./Http.js"; export class VoteManager { constructor() { this.initVoteButtons(); - this.initNotificationDismiss(); } initVoteButtons() { @@ -15,16 +14,4 @@ export class VoteManager { }); }); } - - initNotificationDismiss() { - document.querySelectorAll(".notification-dismiss").forEach((btn) => { - btn.addEventListener("click", () => { - const uid = btn.dataset.uid; - if (!uid) { - return; - } - Http.postForm(`/notifications/mark-read/${uid}`); - }); - }); - } } diff --git a/devplacepy/templates/_comment_section.html b/devplacepy/templates/_comment_section.html index c1bfec1..c642f6e 100644 --- a/devplacepy/templates/_comment_section.html +++ b/devplacepy/templates/_comment_section.html @@ -15,7 +15,7 @@ -
+
{{ item.author['username'] if item.author else '?' }} diff --git a/devplacepy/templates/notifications.html b/devplacepy/templates/notifications.html index b51efdf..c867c46 100644 --- a/devplacepy/templates/notifications.html +++ b/devplacepy/templates/notifications.html @@ -17,14 +17,13 @@
{{ group.label }}
{% for item in group.entries %} - {% set target_url = item.notification.get('target_url', '') %} -
+
{% set actor_username = item.actor['username'] if item.actor else '#' %} {{ actor_username }}
- +
{{ item.notification['message'] }}
{{ item.time_ago }}
diff --git a/devplacepy/utils.py b/devplacepy/utils.py index cc4f644..fb34ffd 100644 --- a/devplacepy/utils.py +++ b/devplacepy/utils.py @@ -212,6 +212,10 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str) if not usernames: return users = get_table("users") + actor = users.find_one(uid=actor_uid) + if not actor: + return + actor_username = actor["username"] seen = set() for username in usernames: if username in seen: @@ -219,7 +223,7 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str) seen.add(username) mentioned = users.find_one(username=username) if mentioned and mentioned["uid"] != actor_uid: - create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url) + create_notification(mentioned["uid"], "mention", f"@{actor_username} mentioned you", actor_uid, target_url) def format_date(dt_str: str, include_time: bool = False) -> str: diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 563802f..3ee752c 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -332,3 +332,182 @@ def test_reply_notification(app_server, browser, seeded_db): ctx_a.close() ctx_b.close() + + +def test_mention_notification_names_actor(app_server, browser, seeded_db): + from tests.conftest import login_user + ctx_a = browser.new_context(viewport={"width": 1400, "height": 900}) + ctx_b = browser.new_context(viewport={"width": 1400, "height": 900}) + pa = ctx_a.new_page() + pb = ctx_b.new_page() + pa.set_default_timeout(15000) + pb.set_default_timeout(15000) + + login_user(pa, seeded_db["alice"]) + login_user(pb, seeded_db["bob"]) + + pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") + pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) + pb.locator(".feed-fab").first.click() + pb.fill("#post-content", "Naming check @alice_test please read") + pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click() + pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded") + + pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + pa.wait_for_timeout(1500) + texts = pa.locator(".notification-text").all_text_contents() + assert any("@bob_test mentioned you" in t for t in texts), f"mention message must name the actor (bob): {texts}" + assert not any("@alice_test mentioned you" in t for t in texts), f"mention message must not name the mentioned user (alice): {texts}" + + ctx_a.close() + ctx_b.close() + + +def test_comment_notification_click_opens_comment(app_server, browser, seeded_db): + from tests.conftest import login_user + ctx_a = browser.new_context(viewport={"width": 1400, "height": 900}) + ctx_b = browser.new_context(viewport={"width": 1400, "height": 900}) + pa = ctx_a.new_page() + pb = ctx_b.new_page() + pa.set_default_timeout(15000) + pb.set_default_timeout(15000) + + login_user(pa, seeded_db["alice"]) + login_user(pb, seeded_db["bob"]) + + pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") + pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) + pa.locator(".feed-fab").first.click() + pa.fill("#post-content", "Post for click navigation test") + pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click() + pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded") + post_url = pa.url + + pb.goto(post_url, wait_until="domcontentloaded") + textarea = pb.locator("form.comment-form textarea[name='content']").first + textarea.wait_for(state="visible", timeout=10000) + textarea.fill("Bob comment that alice should jump to") + pb.locator("button.comment-form-submit").first.click() + pb.wait_for_timeout(1500) + + pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + card = pa.locator(".notification-card[data-href]").filter(has_text="commented on your post").first + card.wait_for(state="visible", timeout=10000) + href = card.get_attribute("data-href") + assert href.startswith("/notifications/open/"), f"unexpected data-href: {href}" + + card.locator(".notification-text").click() + pa.wait_for_url("**/posts/**", timeout=10000, wait_until="domcontentloaded") + assert "#comment-" in pa.url, f"click did not deep-link to a comment: {pa.url}" + + comment_uid = pa.url.split("#comment-")[1] + pa.locator(f"#comment-{comment_uid}").wait_for(state="visible", timeout=10000) + pa.wait_for_selector(f"#comment-{comment_uid}.comment-highlight", timeout=5000) + + pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + pa.wait_for_timeout(1000) + target = pa.locator(f'.notification-card[data-href="{href}"]') + target.wait_for(state="visible", timeout=10000) + assert "unread" not in (target.get_attribute("class") or ""), "opening a notification should mark it read" + + ctx_a.close() + ctx_b.close() + + +def test_follow_notification_click_opens_profile(app_server, browser, seeded_db): + from tests.conftest import login_user + ctx_a = browser.new_context(viewport={"width": 1400, "height": 900}) + ctx_b = browser.new_context(viewport={"width": 1400, "height": 900}) + pa = ctx_a.new_page() + pb = ctx_b.new_page() + pa.set_default_timeout(15000) + pb.set_default_timeout(15000) + + login_user(pa, seeded_db["alice"]) + login_user(pb, seeded_db["bob"]) + + pb.goto(f"{BASE_URL}/profile/alice_test", wait_until="domcontentloaded") + pb.wait_for_timeout(1000) + follow_btn = pb.locator("button:has-text('Follow')") + if follow_btn.is_visible(): + follow_btn.click() + pb.wait_for_timeout(1000) + + pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + card = pa.locator(".notification-card[data-href]").filter(has_text="started following you").first + card.wait_for(state="visible", timeout=10000) + card.locator(".notification-text").click() + pa.wait_for_url("**/profile/bob_test", timeout=10000, wait_until="domcontentloaded") + assert pa.url.endswith("/profile/bob_test"), f"follow notification should open the follower profile: {pa.url}" + + ctx_a.close() + ctx_b.close() + + +def test_message_notification_click_opens_conversation(app_server, browser, seeded_db): + from tests.conftest import login_user + ctx_a = browser.new_context(viewport={"width": 1400, "height": 900}) + ctx_b = browser.new_context(viewport={"width": 1400, "height": 900}) + pa = ctx_a.new_page() + pb = ctx_b.new_page() + pa.set_default_timeout(15000) + pb.set_default_timeout(15000) + + login_user(pa, seeded_db["alice"]) + login_user(pb, seeded_db["bob"]) + + pb.goto(f"{BASE_URL}/messages?search=alice_test", wait_until="domcontentloaded") + pb.wait_for_timeout(2000) + msg_input = pb.locator("input[name='content']").first + msg_input.wait_for(state="visible", timeout=10000) + msg_input.fill("Click-through message from bob") + pb.locator("button[type='submit']").last.click() + pb.wait_for_timeout(1500) + + pa.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + card = pa.locator(".notification-card[data-href]").filter(has_text="sent you a message").first + card.wait_for(state="visible", timeout=10000) + card.locator(".notification-text").click() + pa.wait_for_url("**/messages**", timeout=10000, wait_until="domcontentloaded") + assert "with_uid=" in pa.url, f"message notification should open the conversation: {pa.url}" + + ctx_a.close() + ctx_b.close() + + +def test_vote_notification_click_opens_target(app_server, browser, seeded_db): + from tests.conftest import login_user + ctx_a = browser.new_context(viewport={"width": 1400, "height": 900}) + ctx_b = browser.new_context(viewport={"width": 1400, "height": 900}) + pa = ctx_a.new_page() + pb = ctx_b.new_page() + pa.set_default_timeout(15000) + pb.set_default_timeout(15000) + + login_user(pa, seeded_db["alice"]) + login_user(pb, seeded_db["bob"]) + + pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") + pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) + pb.locator(".feed-fab").first.click() + pb.fill("#post-content", "Post for vote click-through test") + pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click() + pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded") + post_path = "/posts/" + pb.url.split("/posts/")[1] + + pa.goto(f"{BASE_URL}{post_path}", wait_until="domcontentloaded") + pa.wait_for_timeout(1000) + vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first + vote_btn.wait_for(state="visible", timeout=10000) + vote_btn.click() + pa.wait_for_timeout(1500) + + pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + card = pb.locator(".notification-card[data-href]").filter(has_text="++'d").first + card.wait_for(state="visible", timeout=10000) + card.locator(".notification-text").click() + pb.wait_for_url(f"**{post_path}", timeout=10000, wait_until="domcontentloaded") + assert post_path in pb.url, f"vote notification should open the voted post: {pb.url}" + + ctx_a.close() + ctx_b.close()