Added forum.

This commit is contained in:
retoor 2025-07-12 02:41:57 +02:00
parent 94c5ce4989
commit 081d66695c
10 changed files with 1945 additions and 22 deletions

View File

@ -76,6 +76,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from snek.webdav import WebdavApplication
from snek.forum import setup_forum
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes
@ -152,7 +153,7 @@ class Application(BaseApplication):
self.ssh_host = "0.0.0.0"
self.ssh_port = 2242
self.setup_router()
self.forum = None
self.ssh_server = None
self.sync_service = None
self.executor = None
@ -161,6 +162,8 @@ class Application(BaseApplication):
self.mappers = get_mappers(app=self)
self.broadcast_service = None
self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
@ -333,9 +336,10 @@ class Application(BaseApplication):
)
self.webdav = WebdavApplication(self)
self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git)
setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request):

115
src/snek/forum.py Normal file
View File

@ -0,0 +1,115 @@
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
class ForumApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.render_template = self.parent.render_template
# Set up routes
self.setup_routes()
# Set up notification listeners
self.setup_notifications()
@property
def db(self):
return self.parent.db
@property
def services(self):
return self.parent.services
def setup_routes(self):
"""Set up all forum routes"""
# API routes
self.router.add_view("/index.html", ForumIndexView)
self.router.add_route("GET", "/api/forums", ForumView.get_forums)
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread)
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post)
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like)
self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin)
self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock)
# WebSocket route
self.router.add_view("/ws", ForumWebSocketView)
# Static HTML route
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
def setup_notifications(self):
"""Set up notification listeners for WebSocket broadcasting"""
# Forum notifications
self.services.forum.add_notification_listener("forum_created", self.on_forum_event)
# Thread notifications
self.services.thread.add_notification_listener("thread_created", self.on_thread_event)
# Post notifications
self.services.post.add_notification_listener("post_created", self.on_post_event)
self.services.post.add_notification_listener("post_edited", self.on_post_event)
self.services.post.add_notification_listener("post_deleted", self.on_post_event)
# Like notifications
self.services.post_like.add_notification_listener("post_liked", self.on_like_event)
self.services.post_like.add_notification_listener("post_unliked", self.on_like_event)
async def on_forum_event(self, event_type, data):
"""Handle forum events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_thread_event(self, event_type, data):
"""Handle thread events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_post_event(self, event_type, data):
"""Handle post events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_like_event(self, event_type, data):
"""Handle like events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app
def setup_forum(app):
"""Set up forum sub-application"""
forum_app = ForumApplication(app)
app.add_subapp("/forum", forum_app)
app.forum_app = forum_app
# Register models and services if needed
# This would typically be done in your main app initialization
return forum_app

View File

@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object
@ -31,9 +32,14 @@ def get_mappers(app=None):
"channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app),
"push": PushMapper(app=app),
"forum": ForumMapper(app=app),
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
}
)
def get_mapper(name, app=None):
return get_mappers(app=app)[name]

23
src/snek/mapper/forum.py Normal file
View File

@ -0,0 +1,23 @@
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper
class ForumMapper(BaseMapper):
table_name = "forum"
model_class = ForumModel
class ThreadMapper(BaseMapper):
table_name = "thread"
model_class = ThreadModel
class PostMapper(BaseMapper):
table_name = "post"
model_class = PostModel
class PostLikeMapper(BaseMapper):
table_name = "post_like"
model_class = PostLikeModel

View File

@ -14,6 +14,7 @@ from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel
from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.object import Object
@ -33,9 +34,15 @@ def get_models():
"channel_attachment": ChannelAttachmentModel,
"container": Container,
"push_registration": PushRegistrationModel,
"forum": ForumModel,
"thread": ThreadModel,
"post": PostModel,
"post_like": PostLikeModel,
}
)
def get_model(name):
return get_models()[name]

96
src/snek/model/forum.py Normal file
View File

@ -0,0 +1,96 @@
# models/forum.py
from snek.system.model import BaseModel, ModelField
class ForumModel(BaseModel):
"""Forum categories"""
name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
description = ModelField(name="description", required=False, kind=str, max_length=500)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True)
icon = ModelField(name="icon", required=False, kind=str)
position = ModelField(name="position", required=True, kind=int, value=0)
is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
async def get_threads(self, limit=50, offset=0):
return await self.app.services.thread.find(
forum_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
_order_by="is_pinned DESC, last_post_at DESC"
)
async def increment_thread_count(self):
self["thread_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
await self.save()
# models/thread.py
class ThreadModel(BaseModel):
"""Forum threads"""
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
view_count = ModelField(name="view_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", required=False, kind=str)
last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
async def get_posts(self, limit=50, offset=0):
return await self.app.services.post.find(
thread_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
_order_by="created_at ASC"
)
async def increment_view_count(self):
self["view_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
self["last_post_at"] = self.app.services.get_timestamp()
await self.save()
# models/post.py
class PostModel(BaseModel):
"""Forum posts"""
thread_uid = ModelField(name="thread_uid", required=True, kind=str)
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
edited_at = ModelField(name="edited_at", required=False, kind=str)
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False)
like_count = ModelField(name="like_count", required=True, kind=int, value=0)
async def get_author(self):
return await self.app.services.user.get(uid=self["created_by_uid"])
async def is_liked_by(self, user_uid):
return await self.app.services.post_like.exists(
post_uid=self["uid"],
user_uid=user_uid
)
# models/post_like.py
class PostLikeModel(BaseModel):
"""Post likes"""
post_uid = ModelField(name="post_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)

View File

@ -17,32 +17,49 @@ from snek.service.user import UserService
from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService
from snek.system.object import Object
from snek.service.statistics import StatisticsService
from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
_service_registry = {}
def register_service(name, service_cls):
_service_registry[name] = service_cls
register = register_service
@functools.cache
def get_services(app):
return Object(
result = Object(
**{
"user": UserService(app=app),
"channel_member": ChannelMemberService(app=app),
"channel": ChannelService(app=app),
"channel_message": ChannelMessageService(app=app),
"chat": ChatService(app=app),
"socket": SocketService(app=app),
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app),
"user_property": UserPropertyService(app=app),
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
"push": PushService(app=app),
"statistics": StatisticsService(app=app),
name: service_cls(app=app)
for name, service_cls in _service_registry.items()
}
)
result.register = register_service
return result
def get_service(name, app=None):
return get_services(app=app)[name]
# Registering all services
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
register_service("channel_message", ChannelMessageService)
register_service("chat", ChatService)
register_service("socket", SocketService)
register_service("notification", NotificationService)
register_service("util", UtilService)
register_service("drive", DriveService)
register_service("drive_item", DriveItemService)
register_service("user_property", UserPropertyService)
register_service("repository", RepositoryService)
register_service("db", DBService)
register_service("channel_attachment", ChannelAttachmentService)
register_service("container", ContainerService)
register_service("push", PushService)
register_service("statistics", StatisticsService)
register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)

293
src/snek/service/forum.py Normal file
View File

@ -0,0 +1,293 @@
# services/forum.py
from snek.system.service import BaseService
import re
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
"""
Base mix-in that gives a service `add_notification_listener`
and an internal `_dispatch_event` helper.
"""
def __init__(self,*args, **kwargs) -> None:
# Map event name -> list of listener callables
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
super().__init__(*args, **kwargs)
def add_notification_listener(
self, event_name: str, listener: EventListener
) -> None:
"""
Register a callback to be fired when `event_name` happens.
Parameters
----------
event_name : str
The name of the domain event, e.g. "post_created".
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
Your handler; can be async or sync.
"""
if not callable(listener):
raise TypeError("listener must be callable")
self._listeners[event_name].append(listener)
# -----------------------------------------------------------------
# The piece below is optional but makes the service fully usable.
# Call `_dispatch_event` whenever the service actually performs an
# action that should notify listeners.
# -----------------------------------------------------------------
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if hasattr(listener, "__await__"): # async function or coro
await listener(event_name, data)
else: # plain sync function
listener(event_name, data)
class ForumService(BaseForumService):
mapper_name = "forum"
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
if not slug:
slug = self.generate_slug(name)
# Check if slug exists
existing = await self.find_one(slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
model = await self.new()
model["name"] = name
model["description"] = description
model["slug"] = slug
model["created_by_uid"] = created_by_uid
if icon:
model["icon"] = icon
if await self.save(model):
await self.notify("forum_created", model)
return model
raise Exception(f"Failed to create forum: {model.errors}")
def generate_slug(self, text):
# Convert to lowercase and replace spaces with hyphens
slug = text.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug
async def get_active_forums(self):
async for forum in self.find(is_active=True, _order_by="position ASC, created_at ASC"):
yield forum
async def update_last_post(self, forum_uid, thread_uid):
forum = await self.get(uid=forum_uid)
if forum:
forum["last_post_at"] = self.get_timestamp()
forum["last_thread_uid"] = thread_uid
await self.save(forum)
# services/thread.py
class ThreadService(BaseForumService):
mapper_name = "thread"
async def create_thread(self, forum_uid, title, content, created_by_uid):
# Generate slug
slug = self.services.forum.generate_slug(title)
# Check if slug exists in this forum
existing = await self.find_one(forum_uid=forum_uid, slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
# Create thread
thread = await self.new()
thread["forum_uid"] = forum_uid
thread["title"] = title
thread["slug"] = slug
thread["created_by_uid"] = created_by_uid
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
if await self.save(thread):
# Create first post
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=forum_uid,
content=content,
created_by_uid=created_by_uid,
is_first_post=True
)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_thread_count()
await self.services.forum.update_last_post(forum_uid, thread["uid"])
await self.notify("thread_created", {
"thread": thread,
"forum_uid": forum_uid
})
return thread, post
raise Exception(f"Failed to create thread: {thread.errors}")
async def toggle_pin(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"):
return None
thread["is_pinned"] = not thread["is_pinned"]
await self.save(thread)
return thread
async def toggle_lock(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin or thread creator
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
return None
thread["is_locked"] = not thread["is_locked"]
await self.save(thread)
return thread
# services/post.py
class PostService(BaseForumService):
mapper_name = "post"
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
# Check if thread is locked
thread = await self.services.thread.get(uid=thread_uid)
if thread["is_locked"] and not is_first_post:
raise Exception("Thread is locked")
post = await self.new()
post["thread_uid"] = thread_uid
post["forum_uid"] = forum_uid
post["content"] = content
post["created_by_uid"] = created_by_uid
post["is_first_post"] = is_first_post
if await self.save(post):
# Update thread counters
if not is_first_post:
thread["post_count"] += 1
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
await self.services.thread.save(thread)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_post_count()
await self.services.forum.update_last_post(forum_uid, thread_uid)
await self.notify("post_created", {
"post": post,
"thread_uid": thread_uid,
"forum_uid": forum_uid
})
return post
raise Exception(f"Failed to create post: {post.errors}")
async def edit_post(self, post_uid, content, user_uid):
post = await self.get(uid=post_uid)
if not post:
return None
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return None
post["content"] = content
post["edited_at"] = self.get_timestamp()
post["edited_by_uid"] = user_uid
if await self.save(post):
await self.notify("post_edited", post)
return post
return None
async def delete_post(self, post_uid, user_uid):
post = await self.get(uid=post_uid)
if not post:
return False
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return False
# Don't allow deleting first post
if post["is_first_post"]:
return False
post["deleted_at"] = self.get_timestamp()
if await self.save(post):
await self.notify("post_deleted", post)
return True
return False
# services/post_like.py
class PostLikeService(BaseForumService):
mapper_name = "post_like"
async def toggle_like(self, post_uid, user_uid):
# Check if already liked
existing = await self.find_one(post_uid=post_uid, user_uid=user_uid)
if existing:
# Unlike
await self.delete(uid=existing["uid"])
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] = max(0, post["like_count"] - 1)
await self.services.post.save(post)
await self.notify("post_unliked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return False
else:
# Like
like = await self.new()
like["post_uid"] = post_uid
like["user_uid"] = user_uid
if await self.save(like):
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] += 1
await self.services.post.save(post)
await self.notify("post_liked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return True
return None

View File

@ -0,0 +1,829 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum Component</title>
</head>
<body>
<script>
// snek-forum.js - Forum Web Component
class SnekForum extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.ws = null;
this.currentView = 'forums';
this.currentForum = null;
this.currentThread = null;
this.currentPage = 1;
}
connectedCallback() {
this.render();
this.connectWebSocket();
this.loadForums();
}
disconnectedCallback() {
if (this.ws) {
this.ws.close();
}
}
connectWebSocket() {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/forum/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
// Reconnect after 3 seconds
setTimeout(() => this.connectWebSocket(), 3000);
};
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'post_created':
if (this.currentView === 'thread' && this.currentThread?.uid === data.data.thread_uid) {
this.addNewPost(data.data.post);
}
break;
case 'post_edited':
this.updatePost(data.data.post);
break;
case 'post_deleted':
this.removePost(data.data.post.uid);
break;
case 'post_liked':
case 'post_unliked':
this.updatePostLikes(data.data.post_uid);
break;
case 'thread_created':
if (this.currentView === 'forum' && this.currentForum?.uid === data.data.forum_uid) {
this.loadForum(this.currentForum.slug);
}
break;
}
}
subscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'subscribe',
type: type,
id: id
}));
}
}
unsubscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'unsubscribe',
type: type,
id: id
}));
}
}
async fetchAPI(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async loadForums() {
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
this.renderForums(data.forums);
} catch (error) {
console.error('Error loading forums:', error);
}
}
async loadForum(slug) {
try {
const data = await this.fetchAPI(`/forum/api/forums/${slug}?page=${this.currentPage}`);
this.currentView = 'forum';
this.currentForum = data.forum;
this.subscribe('forum', data.forum.uid);
this.renderForum(data);
} catch (error) {
console.error('Error loading forum:', error);
}
}
async loadThread(slug) {
try {
const data = await this.fetchAPI(`/forum/api/threads/${slug}?page=${this.currentPage}`);
this.currentView = 'thread';
this.currentThread = data.thread;
if (this.currentForum) {
this.unsubscribe('forum', this.currentForum.uid);
}
this.subscribe('thread', data.thread.uid);
this.renderThread(data);
} catch (error) {
console.error('Error loading thread:', error);
}
}
async createThread(forumSlug, title, content) {
try {
const data = await this.fetchAPI(`/forum/api/forums/${forumSlug}/threads`, {
method: 'POST',
body: JSON.stringify({ title, content })
});
this.loadThread(data.thread.slug);
} catch (error) {
console.error('Error creating thread:', error);
}
}
async createPost(threadUid, content) {
try {
await this.fetchAPI(`/forum/api/threads/${threadUid}/posts`, {
method: 'POST',
body: JSON.stringify({ content })
});
// Post will be added via WebSocket
} catch (error) {
console.error('Error creating post:', error);
}
}
async toggleLike(postUid) {
try {
const data = await this.fetchAPI(`/forum/api/posts/${postUid}/like`, {
method: 'POST'
});
// Update UI
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
const likeBtn = postEl.querySelector('.like-button');
const likeCount = postEl.querySelector('.like-count');
likeBtn.classList.toggle('liked', data.is_liked);
likeCount.textContent = data.like_count;
}
} catch (error) {
console.error('Error toggling like:', error);
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
min-height: 100vh;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.breadcrumb {
display: flex;
gap: 10px;
align-items: center;
font-size: 14px;
}
.breadcrumb a {
color: #666;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.content {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Forums List */
.forums-list {
padding: 0;
}
.forum-item {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.forum-item:hover {
background: #f9f9f9;
}
.forum-item:last-child {
border-bottom: none;
}
.forum-icon {
width: 48px;
height: 48px;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
background: #eee;
border-radius: 8px;
font-size: 24px;
}
.forum-info {
flex: 1;
}
.forum-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 5px;
}
.forum-description {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.forum-stats {
font-size: 12px;
color: #999;
}
/* Threads List */
.thread-item {
display: flex;
padding: 15px 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.thread-item:hover {
background: #f9f9f9;
}
.thread-item.pinned {
background: #fff9e6;
}
.thread-info {
flex: 1;
}
.thread-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
.thread-meta {
font-size: 13px;
color: #666;
}
.thread-stats {
text-align: right;
font-size: 13px;
color: #666;
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: #eee;
color: #666;
}
.badge.pinned {
background: #ffd700;
color: #333;
}
.badge.locked {
background: #666;
color: white;
}
/* Posts */
.post {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
}
.post-author {
width: 150px;
margin-right: 20px;
text-align: center;
}
.author-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #eee;
margin: 0 auto 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
}
.author-name {
font-weight: 500;
margin-bottom: 5px;
}
.post-content {
flex: 1;
}
.post-header {
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.post-body {
line-height: 1.6;
white-space: pre-wrap;
}
.post-footer {
display: flex;
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
}
.post-action {
font-size: 13px;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.post-action:hover {
color: #333;
}
.like-button.liked {
color: #e74c3c;
}
/* Forms */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.form-group textarea {
min-height: 150px;
resize: vertical;
}
.button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
background: #007bff;
color: white;
}
.button:hover {
background: #0056b3;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-thread-button {
float: right;
margin-bottom: 20px;
}
.reply-form {
padding: 20px;
background: #f9f9f9;
border-top: 2px solid #eee;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 30px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
}
.modal-close {
font-size: 24px;
cursor: pointer;
background: none;
border: none;
color: #999;
}
.modal-close:hover {
color: #333;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
padding: 20px;
}
.page-button {
padding: 5px 10px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
}
.page-button:hover {
background: #f0f0f0;
}
.page-button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
</style>
<div class="container">
<div class="header">
<nav class="breadcrumb">
<a href="#" @click="loadForums">Forums</a>
<span id="breadcrumb-extra"></span>
</nav>
</div>
<div class="content" id="main-content">
<!-- Content will be rendered here -->
</div>
</div>
<!-- New Thread Modal -->
<div class="modal" id="new-thread-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">New Thread</h2>
<button class="modal-close" onclick="this.closest('.modal').classList.remove('show')">&times;</button>
</div>
<form id="new-thread-form">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required minlength="5" maxlength="200">
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content" required minlength="1"></textarea>
</div>
<button type="submit" class="button">Create Thread</button>
</form>
</div>
</div>
`;
// Add event listeners
this.shadowRoot.addEventListener('click', (e) => {
if (e.target.matches('[data-forum-slug]')) {
this.loadForum(e.target.dataset.forumSlug);
} else if (e.target.matches('[data-thread-slug]')) {
this.loadThread(e.target.dataset.threadSlug);
} else if (e.target.matches('.like-button')) {
this.toggleLike(e.target.closest('[data-post-uid]').dataset.postUid);
} else if (e.target.matches('.new-thread-button')) {
this.shadowRoot.getElementById('new-thread-modal').classList.add('show');
}
});
// Form submissions
this.shadowRoot.getElementById('new-thread-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createThread(this.currentForum.slug, formData.get('title'), formData.get('content'));
e.target.reset();
this.shadowRoot.getElementById('new-thread-modal').classList.remove('show');
});
}
renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="forums-list">
${forums.map(forum => `
<div class="forum-item" data-forum-slug="${forum.slug}">
<div class="forum-icon">${forum.icon || '📁'}</div>
<div class="forum-info">
<div class="forum-name">${forum.name}</div>
${forum.description ? `<div class="forum-description">${forum.description}</div>` : ''}
<div class="forum-stats">
${forum.thread_count} threads · ${forum.post_count} posts
</div>
</div>
</div>
`).join('')}
</div>
`;
}
renderForum(data) {
const { forum, threads } = data;
const breadcrumb = this.shadowRoot.getElementById('breadcrumb-extra');
breadcrumb.innerHTML = `<span></span> <span>${forum.name}</span>`;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div style="padding: 20px;">
<button class="button new-thread-button">New Thread</button>
<div style="clear: both;"></div>
<div class="threads-list">
${threads.map(thread => `
<div class="thread-item ${thread.is_pinned ? 'pinned' : ''}" data-thread-slug="${thread.slug}">
<div class="thread-info">
<div class="thread-title">
${thread.title}
${thread.is_pinned ? '<span class="badge pinned">Pinned</span>' : ''}
${thread.is_locked ? '<span class="badge locked">Locked</span>' : ''}
</div>
<div class="thread-meta">
Started by ${thread.author.nick} · ${this.formatDate(thread.created_at)}
</div>
</div>
<div class="thread-stats">
<div>${thread.post_count} replies</div>
<div>${thread.view_count} views</div>
${thread.last_post_author ? `
<div style="margin-top: 5px; font-size: 12px;">
Last: ${thread.last_post_author.nick}<br>
${this.formatDate(thread.last_post_at)}
</div>
` : ''}
</div>
</div>
`).join('')}
</div>
${data.hasMore ? `
<div class="pagination">
<button class="page-button" onclick="this.getRootNode().host.loadForum('${forum.slug}', ${data.page - 1})" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadForum('${forum.slug}', ${data.page + 1})">Next</button>
</div>
` : ''}
</div>
`;
}
renderThread(data) {
const { thread, forum, posts } = data;
const breadcrumb = this.shadowRoot.getElementById('breadcrumb-extra');
breadcrumb.innerHTML = `
<span></span>
<a href="#" onclick="this.getRootNode().host.loadForum('${forum.slug}')">${forum.name}</a>
<span></span>
<span>${thread.title}</span>
`;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="posts-list">
${posts.map(post => `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
${post.edited_at ? `· Edited ${this.formatDate(post.edited_at)}` : ''}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button ${post.is_liked ? 'liked' : ''}">
<span>${post.is_liked ? '❤️' : '🤍'}</span>
<span class="like-count">${post.like_count}</span>
</span>
</div>
</div>
</div>
`).join('')}
</div>
${!thread.is_locked ? `
<div class="reply-form">
<h3>Reply to Thread</h3>
<form id="reply-form">
<div class="form-group">
<textarea name="content" placeholder="Write your reply..." required></textarea>
</div>
<button type="submit" class="button">Post Reply</button>
</form>
</div>
` : `
<div class="reply-form">
<p style="text-align: center; color: #666;">This thread is locked.</p>
</div>
`}
${data.hasMore ? `
<div class="pagination">
<button class="page-button" onclick="this.getRootNode().host.loadThread('${thread.slug}', ${data.page - 1})" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadThread('${thread.slug}', ${data.page + 1})">Next</button>
</div>
` : ''}
`;
// Add reply form listener
const replyForm = this.shadowRoot.getElementById('reply-form');
if (replyForm) {
replyForm.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createPost(thread.uid, formData.get('content'));
e.target.reset();
});
}
}
addNewPost(post) {
const postsList = this.shadowRoot.querySelector('.posts-list');
if (!postsList) return;
const postHtml = `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button">
<span>🤍</span>
<span class="like-count">0</span>
</span>
</div>
</div>
</div>
`;
postsList.insertAdjacentHTML('beforeend', postHtml);
}
updatePost(post) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${post.uid}"]`);
if (postEl) {
const bodyEl = postEl.querySelector('.post-body');
const headerEl = postEl.querySelector('.post-header');
if (bodyEl) bodyEl.textContent = post.content;
if (headerEl && post.edited_at) {
headerEl.innerHTML += ` · Edited ${this.formatDate(post.edited_at)}`;
}
}
}
removePost(postUid) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
postEl.remove();
}
}
formatDate(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
return date.toLocaleDateString();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
customElements.define('snek-forum', SnekForum);
</script>
<snek-forum></snek-forum>
</body>
</html>

533
src/snek/view/forum.py Normal file
View File

@ -0,0 +1,533 @@
# views/forum.py
from snek.system.view import BaseView
from aiohttp import web
import json
from aiohttp import web
from snek.system.view import BaseView
class ForumIndexView(BaseView):
login_required = True
async def get(self):
if self.login_required and not self.session.get("logged_in"):
return web.HTTPFound("/")
channel = await self.services.channel.get(
uid=self.request.match_info.get("channel")
)
if not channel:
user = await self.services.user.get(
uid=self.request.match_info.get("channel")
)
if user:
channel = await self.services.channel.get_dm(
self.session.get("uid"), user["uid"]
)
if channel:
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
if not channel:
return web.HTTPNotFound()
channel_member = await self.app.services.channel_member.get(
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPNotFound()
channel_member["new_count"] = 0
await self.app.services.channel_member.save(channel_member)
user = await self.services.user.get(uid=self.session.get("uid"))
messages = [
await self.app.services.channel_message.to_extended_dict(message)
for message in await self.app.services.channel_message.offset(
channel["uid"]
)
]
for message in messages:
await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"]
)
name = await channel_member.get_name()
return await self.render_template(
"forum.html",
{"name": name, "channel": channel, "user": user, "messages": messages},
)
class ForumView(BaseView):
"""REST API endpoints for forum"""
login_required = True
async def get_forums(self):
request = self
self = request.app
"""GET /forum/api/forums - Get all active forums"""
forums = []
async for forum in self.services.forum.get_active_forums():
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"],
"last_post_at": forum["last_post_at"],
"last_thread_uid": forum["last_thread_uid"]
})
return web.json_response({"forums": forums})
async def get_forum(self):
request = self
self = request.app
"""GET /forum/api/forums/:slug - Get forum by slug"""
slug = self.request.match_info["slug"]
forum = await self.services.forum.find_one(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
# Get threads
threads = []
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
async for thread in forum.get_threads(limit=limit, offset=offset):
# Get author info
author = await self.services.user.get(uid=thread["created_by_uid"])
last_post_author = None
if thread["last_post_by_uid"]:
last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
threads.append({
"uid": thread["uid"],
"title": thread["title"],
"slug": thread["slug"],
"is_pinned": thread["is_pinned"],
"is_locked": thread["is_locked"],
"view_count": thread["view_count"],
"post_count": thread["post_count"],
"created_at": thread["created_at"],
"last_post_at": thread["last_post_at"],
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
},
"last_post_author": {
"uid": last_post_author["uid"],
"username": last_post_author["username"],
"nick": last_post_author["nick"],
"color": last_post_author["color"]
} if last_post_author else None
})
return web.json_response({
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"]
},
"threads": threads,
"page": page,
"hasMore": len(threads) == limit
})
async def create_thread(self):
request = self
self = request.app
"""POST /forum/api/forums/:slug/threads - Create new thread"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
slug = self.request.match_info["slug"]
forum = await self.services.forum.find_one(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
data = await self.request.json()
title = data.get("title", "").strip()
content = data.get("content", "").strip()
if not title or not content:
return web.json_response({"error": "Title and content required"}, status=400)
try:
thread, post = await self.services.thread.create_thread(
forum_uid=forum["uid"],
title=title,
content=content,
created_by_uid=self.request.session["uid"]
)
return web.json_response({
"thread": {
"uid": thread["uid"],
"slug": thread["slug"],
"forum_slug": forum["slug"]
}
})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
async def get_thread(self):
request = self
self = request.app
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
thread_slug = self.request.match_info["thread_slug"]
thread = await self.services.thread.find_one(slug=thread_slug)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
# Increment view count
await thread.increment_view_count()
# Get forum
forum = await self.services.forum.get(uid=thread["forum_uid"])
# Get posts
posts = []
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
current_user_uid = self.request.session.get("uid")
async for post in thread.get_posts(limit=limit, offset=offset):
author = await post.get_author()
is_liked = False
if current_user_uid:
is_liked = await post.is_liked_by(current_user_uid)
posts.append({
"uid": post["uid"],
"content": post["content"],
"created_at": post["created_at"],
"edited_at": post["edited_at"],
"is_first_post": post["is_first_post"],
"like_count": post["like_count"],
"is_liked": is_liked,
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
}
})
# Get thread author
thread_author = await self.services.user.get(uid=thread["created_by_uid"])
return web.json_response({
"thread": {
"uid": thread["uid"],
"title": thread["title"],
"slug": thread["slug"],
"is_pinned": thread["is_pinned"],
"is_locked": thread["is_locked"],
"view_count": thread["view_count"],
"post_count": thread["post_count"],
"created_at": thread["created_at"],
"author": {
"uid": thread_author["uid"],
"username": thread_author["username"],
"nick": thread_author["nick"],
"color": thread_author["color"]
}
},
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"slug": forum["slug"]
},
"posts": posts,
"page": page,
"hasMore": len(posts) == limit
})
async def create_post(self):
request = self
self = request.app
"""POST /forum/api/threads/:thread_uid/posts - Create new post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
try:
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=thread["forum_uid"],
content=content,
created_by_uid=self.request.session["uid"]
)
author = await post.get_author()
return web.json_response({
"post": {
"uid": post["uid"],
"content": post["content"],
"created_at": post["created_at"],
"like_count": post["like_count"],
"is_liked": False,
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
}
}
})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
async def edit_post(self):
request = self
self = request.app
"""PUT /forum/api/posts/:post_uid - Edit post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"]
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
post = await self.services.post.edit_post(
post_uid=post_uid,
content=content,
user_uid=self.request.session["uid"]
)
if not post:
return web.json_response({"error": "Cannot edit post"}, status=403)
return web.json_response({
"post": {
"uid": post["uid"],
"content": post["content"],
"edited_at": post["edited_at"]
}
})
async def delete_post(self):
request = self
self = request.app
"""DELETE /forum/api/posts/:post_uid - Delete post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"]
success = await self.services.post.delete_post(
post_uid=post_uid,
user_uid=self.request.session["uid"]
)
if not success:
return web.json_response({"error": "Cannot delete post"}, status=403)
return web.json_response({"success": True})
async def toggle_like(self):
request = self
self = request.app
"""POST /forum/api/posts/:post_uid/like - Toggle post like"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"]
is_liked = await self.services.post_like.toggle_like(
post_uid=post_uid,
user_uid=self.request.session["uid"]
)
if is_liked is None:
return web.json_response({"error": "Failed to toggle like"}, status=400)
# Get updated post
post = await self.services.post.get(uid=post_uid)
return web.json_response({
"is_liked": is_liked,
"like_count": post["like_count"]
})
async def toggle_pin(self):
request = self
self = request.app
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_pin(
thread_uid=thread_uid,
user_uid=self.request.session["uid"]
)
if not thread:
return web.json_response({"error": "Cannot toggle pin"}, status=403)
return web.json_response({"is_pinned": thread["is_pinned"]})
async def toggle_lock(self):
request = self
self = request.app
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_lock(
thread_uid=thread_uid,
user_uid=self.request.session["uid"]
)
if not thread:
return web.json_response({"error": "Cannot toggle lock"}, status=403)
return web.json_response({"is_locked": thread["is_locked"]})
# views/forum_websocket.py
class ForumWebSocketView(BaseView):
"""WebSocket view for real-time forum updates"""
async def websocket_handler(self):
ws = web.WebSocketResponse()
await ws.prepare(self.request)
# Store WebSocket connection
ws_id = self.services.generate_uid()
if not hasattr(self.app, 'forum_websockets'):
self.app.forum_websockets = {}
user_uid = self.request.session.get("uid")
self.app.forum_websockets[ws_id] = {
"ws": ws,
"user_uid": user_uid,
"subscriptions": set()
}
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
data = json.loads(msg.data)
await self.handle_ws_message(ws_id, data)
elif msg.type == web.WSMsgType.ERROR:
break
finally:
# Clean up
if ws_id in self.app.forum_websockets:
del self.app.forum_websockets[ws_id]
return ws
async def handle_ws_message(self, ws_id, data):
"""Handle incoming WebSocket messages"""
action = data.get("action")
if action == "subscribe":
# Subscribe to forum/thread updates
target_type = data.get("type") # "forum" or "thread"
target_id = data.get("id")
if target_type and target_id:
subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].add(subscription)
# Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({
"type": "subscribed",
"subscription": subscription
}))
elif action == "unsubscribe":
target_type = data.get("type")
target_id = data.get("id")
if target_type and target_id:
subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription)
# Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({
"type": "unsubscribed",
"subscription": subscription
}))
@staticmethod
async def broadcast_update(app, event_type, data):
"""Broadcast updates to subscribed WebSocket clients"""
if not hasattr(app, 'forum_websockets'):
return
# Determine subscription targets based on event
targets = set()
if event_type in ["thread_created", "post_created", "post_edited", "post_deleted"]:
if "forum_uid" in data:
targets.add(f"forum:{data['forum_uid']}")
if event_type in ["post_created", "post_edited", "post_deleted", "post_liked", "post_unliked"]:
if "thread_uid" in data:
targets.add(f"thread:{data['thread_uid']}")
# Send to subscribed clients
for ws_id, ws_data in app.forum_websockets.items():
if ws_data["subscriptions"] & targets:
try:
await ws_data["ws"].send_str(json.dumps({
"type": event_type,
"data": data
}))
except Exception:
pass