Update
All checks were successful
DevPlace CI / test (push) Successful in 6m10s

This commit is contained in:
retoor 2026-05-25 16:16:53 +02:00
parent 347e5f0f31
commit a5c71fd2f8
15 changed files with 309 additions and 47 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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)

View File

@ -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(),
})

View File

@ -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)

View File

@ -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)

View File

@ -31,6 +31,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
cursor: pointer;
}
.notification-card.unread {

View File

@ -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;

View File

@ -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();

View File

@ -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);
});
});
}
}

View File

@ -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}`);
});
});
}
}

View File

@ -15,7 +15,7 @@
</form>
</div>
<div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-body" id="comment-{{ item.comment['uid'] }}" data-comment-uid="{{ item.comment['uid'] }}">
<div class="comment-header">
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">

View File

@ -17,14 +17,13 @@
<div class="notification-group">
<div class="notification-group-label">{{ group.label }}</div>
{% for item in group.entries %}
{% set target_url = item.notification.get('target_url', '') %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}" data-href="/notifications/open/{{ item.notification['uid'] }}">
{% set actor_username = item.actor['username'] if item.actor else '#' %}
<a href="/profile/{{ actor_username }}" style="flex-shrink:0">
<img src="{{ avatar_url('multiavatar', actor_username, 32) }}" class="avatar-img avatar-sm" alt="{{ actor_username }}" loading="lazy">
</a>
<div class="notification-body">
<div class="notification-text">{% if target_url %}<a href="{{ target_url }}" style="color:inherit;text-decoration:none">{% endif %}{{ item.notification['message'] }}{% if target_url %}</a>{% endif %}</div>
<div class="notification-text">{{ item.notification['message'] }}</div>
<div class="notification-time">{{ item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">

View File

@ -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:

View File

@ -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()