Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/PULL_REQUEST_TEMPLATE/development-to-staging.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Leave a category empty (or delete its header) if nothing applies.
-->

## User-visible improvements (UVIs)
## Summary

### Features
-
Expand All @@ -21,7 +21,3 @@

## Technical notes
<!-- Migrations, env vars, feature flags, anything a reviewer needs to know -->

## Test plan
- [ ] CI passes
- [ ] Smoke test on staging after deploy
4 changes: 0 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE/staging-to-main.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,3 @@

## Technical notes
<!-- Migrations, env vars, feature flags, rollout/rollback risk -->

## Test plan
- [ ] CI passes
- [ ] Verified on staging
2 changes: 1 addition & 1 deletion character/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ def linkable(self):
)


class CharacterManager(models.Manager.from_queryset(CharacterQuerySet)):
class CharacterManager(models.Manager.from_queryset(CharacterQuerySet)): # type: ignore[misc]
pass


Expand Down
7 changes: 6 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,12 @@ def has_add_permission(self, request):
@admin.action(description="Publish selected announcements")
def publish_selected_announcements(_modeladmin, _request, queryset):
now = timezone.now()
queryset.update(is_published=True, published_at=now)
# Save individually (not queryset.update()) so Announcement.save()
# broadcasts the "announcement_published" WebSocket event per row.
for announcement in queryset:
announcement.is_published = True
announcement.published_at = now
announcement.save()


@admin.action(description="Unpublish selected announcements")
Expand Down
27 changes: 27 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,33 @@ class Meta:
def __str__(self):
return self.title

def save(self, *args, **kwargs):
was_published = (
Announcement.objects.filter(pk=self.pk, is_published=True).exists()
if self.pk
else False
)
super().save(*args, **kwargs)
if self.is_published and not was_published:
from django.db import transaction

transaction.on_commit(self._broadcast_published)

def _broadcast_published(self):
from asgiref.sync import async_to_sync

from gameplay.utils import send_group_message

async_to_sync(send_group_message)(
"online_users",
{
"type": "action",
"action": "announcement_published",
"data": {"id": self.id},
"success": True,
},
)


class PlayerAnnouncementState(models.Model):
player = models.ForeignKey(
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/context/WebSocketContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// context/WebSocketContext.tsx
import { useRef, useCallback, useEffect } from 'react';
import type { ReactNode, ReactElement } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useGame } from '../hooks/useGame';
import { useOnlineCount } from './OnlineCountContext';
import { useToast } from '../hooks/useToast';
Expand All @@ -10,6 +11,10 @@ import { handleGlobalWebSocketEvent } from '../websockets/handleGlobalWebSocketE
import { useMaintenanceStatus } from '../hooks/useMaintenanceStatus';
import { useMaintenanceContext } from './MaintenanceContext';
import { WebSocketContext } from './webSocketContext';
import {
ANNOUNCEMENTS_QUERY_KEY,
ANNOUNCEMENT_UNREAD_QUERY_KEY,
} from '../hooks/useAnnouncements';
import type { ActivityTimerApiData, IncomingWebSocketMessage, OutgoingWebSocketMessage } from '../types';

// ---------------------------------------------------------------------------
Expand All @@ -36,6 +41,7 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement =>
const { showToast } = useToast();
const { refetch: maintenanceRefetch } = useMaintenanceStatus();
const { setMaintenance } = useMaintenanceContext();
const queryClient = useQueryClient();
// Set stores message handler callbacks registered by child components
const eventHandlersRef = useRef<Set<(data: IncomingWebSocketMessage) => void>>(new Set());
const wsEnabled = Boolean(!authLoading && isAuthenticated && player?.id);
Expand All @@ -52,14 +58,25 @@ export const WebSocketProvider = ({ children }: ProviderProps): ReactElement =>
});
}, [loadFromServer, player?.is_premium, freeTimerLimitSeconds]);

const onAnnouncementPublished = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ANNOUNCEMENTS_QUERY_KEY });
queryClient.invalidateQueries({ queryKey: ANNOUNCEMENT_UNREAD_QUERY_KEY });
}, [queryClient]);

const onMessage = useCallback((data: IncomingWebSocketMessage) => {
if (data.type === 'online_count') {
setOnlinePlayerCount(data.count);
}
//console.log("[WS Provider] showToast:", showToast);
handleGlobalWebSocketEvent(data, { showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate });
handleGlobalWebSocketEvent(data, {
showToast,
maintenanceRefetch,
setMaintenance,
onActivityTimerUpdate,
onAnnouncementPublished,
});
eventHandlersRef.current.forEach((handler) => handler(data));
}, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate]);
}, [showToast, maintenanceRefetch, setMaintenance, setOnlinePlayerCount, onActivityTimerUpdate, onAnnouncementPublished]);

const onError = useCallback(() => {
console.error('WebSocket connection error');
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/types/timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export interface WebSocketErrorMessage extends WebSocketMessageBase {
/** Server-initiated action message (maintenance refresh, game events) */
export interface WebSocketActionMessage {
type: "action";
action: "refresh" | "load-game" | "activity_timer_update";
action: "refresh" | "load-game" | "activity_timer_update" | "announcement_published";
message?: string;
maintenance_active?: boolean;
name?: string;
Expand All @@ -168,8 +168,12 @@ export interface WebSocketActionMessage {
* Present when action is "activity_timer_update" — pushed whenever another
* of this player's sessions (tabs/devices) starts, labels, or submits the
* activity timer, so every open session can reconcile to server state.
*
* Present when action is "announcement_published" — the id of the
* newly-published Announcement, so callers can invalidate the
* announcements list and unread-count queries.
*/
data?: { activity_timer: ActivityTimerApiData };
data?: { activity_timer: ActivityTimerApiData } | { id: number };
}

/** Generic server message (currently unused payload) */
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/websockets/handleGlobalWebSocketEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ interface HandleGlobalWebSocketEventOptions {
* useActivityTimer's loadFromServer.
*/
onActivityTimerUpdate?: (activityTimer: ActivityTimerApiData) => void;
/**
* Called when the server announces a newly-published Announcement, so the
* caller can refetch the announcements list / unread-count queries.
*/
onAnnouncementPublished?: () => void;
}

export async function handleGlobalWebSocketEvent(
data: IncomingWebSocketMessage,
{ showToast, maintenanceRefetch, setMaintenance, onActivityTimerUpdate }: HandleGlobalWebSocketEventOptions,
{
showToast,
maintenanceRefetch,
setMaintenance,
onActivityTimerUpdate,
onAnnouncementPublished,
}: HandleGlobalWebSocketEventOptions,
): Promise<void> {
switch (data.type) {
case 'notification':
Expand Down Expand Up @@ -74,10 +85,13 @@ export async function handleGlobalWebSocketEvent(
console.log("[WS] Django consumer 'load-game' message not currently in use.");
break;
case 'activity_timer_update':
if (data.data?.activity_timer) {
if (data.data && 'activity_timer' in data.data) {
onActivityTimerUpdate?.(data.data.activity_timer);
}
break;
case 'announcement_published':
onAnnouncementPublished?.();
break;
default:
console.warn('[WS] Unknown action:', data);
}
Expand Down
Loading