Improve chat replies, hubs API & UI

Backend: enrich message reply data (include created_at and media_files) and ensure chat owners are treated as members; tighten/extend permission checks and message query filters; fix hub routers so moderators/tags routes are resolved before hub detail; accept hub id from request.data in hub permission/tag views; add PostHub serializer and expose hub_detail on posts.

Frontend: update generated API models (postHub, replyTo, members_detail, hub_detail); add hub-related pages/routes and components (HubCard, HubHeader, Tags) and a hub posts feed hook; rework message UI and composer to show richer reply previews (media thumbnails, timestamps), adjust video preload to metadata; add tag selection UI to PostComposer and wire hub tags fetching.

Also: minor UI/UX improvements and generated model exports updated to match backend changes.
This commit is contained in:
2026-06-07 00:24:21 +02:00
parent 6422fefe46
commit cb23abeb5f
43 changed files with 1522 additions and 321 deletions

View File

@@ -9,7 +9,11 @@ class IsChatMember(IsAuthenticated):
"""
def has_object_permission(self, request, view, obj):
return request.user.is_superuser or obj.members.filter(pk=request.user.pk).exists()
return (
request.user.is_superuser
or obj.owner == request.user
or obj.members.filter(pk=request.user.pk).exists()
)
class CanManageChat(IsAuthenticated):

View File

@@ -43,11 +43,12 @@ class MessageHistorySerializer(serializers.ModelSerializer):
class ReplyToSerializer(serializers.ModelSerializer):
sender = MessageSenderSerializer(read_only=True)
media_files = MessageFileSerializer(many=True, read_only=True)
class Meta:
model = Message
fields = ['id', 'content', 'sender']
read_only_fields = ['id', 'content', 'sender']
fields = ['id', 'content', 'sender', 'created_at', 'media_files']
read_only_fields = ['id', 'content', 'sender', 'created_at', 'media_files']
class MessageSerializer(serializers.ModelSerializer):
@@ -69,23 +70,34 @@ class MessageSerializer(serializers.ModelSerializer):
if not reply_to_id:
return None
try:
msg = Message.all_objects.select_related('sender').get(pk=reply_to_id)
msg = Message.all_objects.select_related('sender').prefetch_related('media_files').get(pk=reply_to_id)
except Message.DoesNotExist:
return None
from django.conf import settings
sender_data = None
if msg.sender:
from django.conf import settings
avatar = (settings.MEDIA_URL + msg.sender.avatar.name) if msg.sender.avatar else None
sender_data = {'id': msg.sender.id, 'username': msg.sender.username, 'avatar': avatar}
else:
sender_data = {'id': 0, 'username': '', 'avatar': None}
media_files_data = []
if not msg.is_deleted:
for f in msg.media_files.all():
media_files_data.append({
'id': f.id,
'file': settings.MEDIA_URL + f.file.name if f.file else '',
'media_type': f.media_type,
'uploaded_at': f.uploaded_at.isoformat(),
})
return {
'id': msg.id,
# content=None signals the frontend to show the deleted tombstone
'content': None if msg.is_deleted else msg.content,
'sender': sender_data,
'created_at': msg.created_at.isoformat(),
'media_files': media_files_data,
}
class Meta:

View File

@@ -54,6 +54,10 @@ class ChatViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer):
chat = serializer.save(owner=self.request.user)
# Ensure the creator is always a member so they pass membership checks.
chat.members.add(self.request.user)
if chat.chat_type == Chat.ChatType.DM:
other = chat.members.exclude(pk=self.request.user.pk).first()
if other:
@@ -192,8 +196,7 @@ class MessageViewSet(viewsets.ModelViewSet):
qs = Message.objects.select_related('sender', 'chat').prefetch_related('media_files', 'reactions')
if user.is_superuser:
return qs
# Only messages from chats the user is a member of
return qs.filter(chat__members=user)
return qs.filter(Q(chat__members=user) | Q(chat__owner=user)).distinct()
def perform_update(self, serializer):
message = serializer.instance
@@ -233,7 +236,7 @@ class MessageViewSet(viewsets.ModelViewSet):
ser.is_valid(raise_exception=True)
chat = ser.validated_data['chat']
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists():
if not request.user.is_superuser and not chat.members.filter(pk=request.user.pk).exists() and chat.owner != request.user:
raise PermissionDenied('You are not a member of this chat.')
message = Message.objects.create(

View File

@@ -1,9 +1,19 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import HubViewSet, HubPermissionViewSet, TagsViewSet
router = DefaultRouter()
router.register('', HubViewSet, basename='hub')
router.register('moderators', HubPermissionViewSet, basename='hub-moderator')
router.register('tags', TagsViewSet, basename='hub-tag')
hub_router = DefaultRouter()
hub_router.register('', HubViewSet, basename='hub')
urlpatterns = router.urls
moderators_router = DefaultRouter()
moderators_router.register('', HubPermissionViewSet, basename='hub-moderator')
tags_router = DefaultRouter()
tags_router.register('', TagsViewSet, basename='hub-tag')
# moderators/ and tags/ must be declared BEFORE the hub router urls so that
# Django resolves them before the hub's generic /{pk}/ pattern can swallow them.
urlpatterns = [
path('moderators/', include(moderators_router.urls)),
path('tags/', include(tags_router.urls)),
] + hub_router.urls

View File

@@ -223,7 +223,11 @@ class HubPermissionViewSet(viewsets.ModelViewSet):
filterset_fields = ['user', 'changing_name', 'changing_description', 'changing_icon', 'changing_banner', 'managing_members', 'managing_posts', 'managing_chats']
def _get_hub(self):
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
hub_id = (
self.kwargs.get('hub_pk')
or self.request.query_params.get('hub')
or self.request.data.get('hub')
)
return Hub.objects.get(pk=hub_id)
def get_queryset(self):
@@ -279,10 +283,16 @@ class TagsViewSet(viewsets.ModelViewSet):
ordering = ['name']
def _get_hub(self):
hub_id = self.kwargs.get('hub_pk') or self.request.query_params.get('hub')
hub_id = (
self.kwargs.get('hub_pk')
or self.request.query_params.get('hub')
or self.request.data.get('hub')
)
return Hub.objects.get(pk=hub_id)
def get_queryset(self):
if self.kwargs.get('pk'):
return Tags.objects.all()
return Tags.objects.filter(hub=self._get_hub())
def perform_create(self, serializer):

View File

@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
from rest_framework import serializers
from .models import Post, PostContent, PostVote, PostSave
from social.hubs.serializers import TagsSerializer
from social.hubs.models import Hub
User = get_user_model()
@@ -21,10 +22,17 @@ class PostContentSerializer(serializers.ModelSerializer):
read_only_fields = ['mime_type']
class PostHubSerializer(serializers.ModelSerializer):
class Meta:
model = Hub
fields = ['id', 'name', 'icon']
class PostSerializer(serializers.ModelSerializer):
contents = PostContentSerializer(many=True, read_only=True)
tags = TagsSerializer(many=True, read_only=True)
author_detail = AuthorMinimalSerializer(source='author', read_only=True)
hub_detail = PostHubSerializer(source='hub', read_only=True)
vote_score = serializers.SerializerMethodField()
user_vote = serializers.SerializerMethodField()
reply_count = serializers.IntegerField(read_only=True, default=0)
@@ -36,7 +44,7 @@ class PostSerializer(serializers.ModelSerializer):
fields = [
'id', 'content', 'created_at', 'updated_at',
'author', 'author_detail',
'hub', 'reply_to',
'hub', 'hub_detail', 'reply_to',
'tags', 'contents',
'vote_score', 'user_vote', 'reply_count', 'is_saved', 'save_count',
]