<script type="text/javascript">
const ROOT_PATH = window.location.origin; // always match current host to avoid CORS
const CADDY_PATH = `${ROOT_PATH}/hub`;
window.Application = {};
const APP_CONTEXT = window.Application;
window.APP_CONTEXT = APP_CONTEXT; // keep a single shared reference for controllers
APP_CONTEXT.toast = {}; // Toast dans ToasPageLoading
try {
const storedThemeVersion = window.localStorage?.getItem('themeVersion');
APP_CONTEXT.themeVersion = storedThemeVersion || document.documentElement.getAttribute('data-theme') || 'v2';
document.documentElement.setAttribute('data-theme', APP_CONTEXT.themeVersion);
} catch (e) {
APP_CONTEXT.themeVersion = document.documentElement.getAttribute('data-theme') || 'v2';
}
</script>
{% if app.user %}
<script type="text/javascript">
APP_CONTEXT.APP_USER = {
'id': '{{app.user.id}}',
'email': '{{app.user.email}}',
'username': '{{app.user.username}}',
'image': '{{app.user.image}}',
'isPremium': {{ app.user.isPremium ? 'true' : 'false' }},
'locationOptIn': {{ app.user.locationOptIn ? 'true' : 'false' }},
'roles': {{ app.user.roles|json_encode|raw }},
'var' : {},
'function' : {},
'history' : [],
}
APP_CONTEXT.pendingMessages = [];
APP_CONTEXT.function = {};
APP_CONTEXT.var = {};
APP_CONTEXT.toast = {}; // Toast dans ToasPageLoading
/**
* JSON command utils
*/
APP_CONTEXT.scrollToBottom = (element = document.querySelector('.messageContainer')) => {
if (!element) {
return;
}
/** Temps de traitement du .dispatch(), setState(), etc... */
setTimeout(() => {
element.scrollTop = element.scrollHeight ?? 99999;
}, 10);
}
APP_CONTEXT.executeCommands = function(commands){
const list = Array.isArray(commands) ? commands : (commands && typeof commands === 'object' ? [commands] : []);
for (const command of list) {
if (!command) {
continue;
}
if (!command.command) {
if (command.notification || command.id) {
if (APP_CONTEXT.Notification?.Global?.toast) {
APP_CONTEXT.Notification.Global.toast(command);
}
}
continue;
}
const context = {...APP_CONTEXT};
APP_CONTEXT.executeFunctionByName(command.command, context, command); // Passer toute la commande envoyer par le serveur
}
}
APP_CONTEXT.executeFunctionByName = function(functionName, context, args) {
// console.log('executeFunctionByName', functionName, context, args);
var namespaces = functionName.split(".");
var func = namespaces.pop();
for(var i = 0; i < namespaces.length; i++) {
if (!context || typeof context !== 'object') {
break;
}
context = context[namespaces[i]]; // context est remplacer par le premier context trouvé, puis cherche dans celui-ci pour tout les namespace (Post.article.page)
}
if (!context || typeof context[func] !== 'function') {
if (functionName.startsWith('Notification.') && APP_CONTEXT.Notification?.Global?.toast) {
return APP_CONTEXT.Notification.Global.toast(args);
}
return APP_CONTEXT.deferCommand(functionName, args);
}
return context[func].apply(context, [args]); // /!\ [args] != args : apply() attent un tableau à décomposer
}
APP_CONTEXT.deferCommand = function(functionName, args) {
// Queue messages until the SSE controller is ready
if (functionName === 'Message.add') {
APP_CONTEXT.pendingMessages = APP_CONTEXT.pendingMessages || [];
APP_CONTEXT.pendingMessages.push(args);
} else {
APP_CONTEXT.pendingCommands = APP_CONTEXT.pendingCommands || [];
APP_CONTEXT.pendingCommands.push({ functionName, args });
}
console.warn('[SSE] Command deferred', functionName, args);
}
/**
* SSE Subscribe to the topic and set commands listener.
* Only started once the Stimulus SSE controller marks itself ready to avoid early command defers.
*/
APP_CONTEXT.SSE = APP_CONTEXT.SSE || {};
APP_CONTEXT.SSE.start = function setApplicationSSELitener() {
if (APP_CONTEXT.SSE.eventSource) {
return; // already started
}
const uri = ROOT_PATH + '/hub'
const url = new URL(uri); // generate URL
// url.searchParams.append('topic', 'GLOBALTOPIC') // add topic params
url.searchParams.append('topic', '{{ app.user.flux.topicLabel }}') // add topic params
const eventSource = new EventSource(url, { withCredentials: true }) // set event source
APP_CONTEXT.SSE.eventSource = eventSource;
eventSource.addEventListener('message', event => { // set callback
console.log('SSE Event: ', JSON.parse(event.data));
APP_CONTEXT.executeCommands(JSON.parse(event.data));
})
}
/**
* Récupère les notifications en attente (file backend).
*/
APP_CONTEXT.fetchPendingNotifications = async function(attempt = 0) {
const toastReady = APP_CONTEXT.toast && typeof APP_CONTEXT.toast.setShow === 'function';
if (!toastReady && attempt < 10) {
// Attendre que le composant ToastPageLoading enregistre ses setters
setTimeout(() => APP_CONTEXT.fetchPendingNotifications(attempt + 1), 300);
return;
}
try {
const resp = await fetch('/api/notifications/pending', { credentials: 'include' });
if (!resp.ok) return;
const data = await resp.json();
let toastScheduled = false;
if (data.total && data.total > 0) {
const latest = data.items?.[data.items.length - 1];
const unreadByConversation = {};
(data.items || []).forEach((item) => {
if (!item?.conversationId) return;
const convKey = String(item.conversationId).match(/(\d+)(?!.*\d)/);
const key = convKey ? convKey[1] : String(item.conversationId);
unreadByConversation[key] = (unreadByConversation[key] || 0) + 1;
});
const preview = latest?.content || '';
const sender = latest?.sender || 'Nouveau message';
const msg = preview
? `${preview}`.substring(0, 140)
: (data.total === 1
? '1 nouveau message reçu pendant votre absence.'
: `${data.total} nouveaux messages reçus pendant votre absence.`);
if (APP_CONTEXT.toast?.setContent) {
APP_CONTEXT.toast.setTitle(sender);
APP_CONTEXT.toast.setContent(msg);
APP_CONTEXT.toast.setRedirectUrl('/conversation');
APP_CONTEXT.toast.setShow(true);
toastScheduled = true;
}
APP_CONTEXT.APP_USER = APP_CONTEXT.APP_USER || {};
APP_CONTEXT.APP_USER.pendingNotifications = data.total;
const unreadList = data.items?.map(i => ({
conversationId: i.conversationId,
messageId: i.messageId,
content: i.content,
sender: i.sender,
date: i.createdAt * 1000
})) || [];
const messageNotifications = (data.items || []).map((item) => {
const sender = item.sender || 'Quelqu’un';
const content = item.content || '';
const createdAt = item.createdAt ? new Date(item.createdAt * 1000).toISOString() : new Date().toISOString();
return {
id: item.messageId ? `msg-${item.messageId}` : `msg-${item.conversationId || 'unknown'}-${item.createdAt || Date.now()}`,
type: 'Message.offline',
message: content ? `${sender}: ${content}` : `${sender} vous a envoyé un message`,
redirectUrl: item.conversationId ? `/conversation/${item.conversationId}` : '/conversation',
dateCreation: createdAt,
sourceUser: item.sender ? { username: item.sender } : null,
conversationId: item.conversationId,
messageId: item.messageId,
isPendingMessage: true,
};
});
if (window.NotificationBus?.setUnreadMap) {
window.NotificationBus.setUnreadMap(unreadByConversation);
window.NotificationBus.setUnreadTotal(data.total);
window.NotificationBus.emitNewMessages({
unreadCount: data.total,
unread: unreadList,
unreadByConversation,
});
if (messageNotifications.length > 0 && window.NotificationBus.appendNotifications) {
window.NotificationBus.appendNotifications(messageNotifications);
window.NotificationBus.emitNotifications(messageNotifications);
}
} else {
try {
window.localStorage.setItem('app_unread_conversations', data.total);
window.localStorage.setItem('app_unread_by_conv', JSON.stringify(unreadByConversation));
window.localStorage.setItem('app_unread_conversations_map', JSON.stringify(unreadByConversation)); // legacy compat
} catch (e) {}
window.dispatchEvent(new CustomEvent('app:newMessages', {
detail: {
unreadCount: data.total,
unread: unreadList,
unreadByConversation,
}
}));
if (messageNotifications.length > 0) {
APP_CONTEXT.notificationsList = [
...messageNotifications,
...(APP_CONTEXT.notificationsList || []),
];
window.dispatchEvent(new CustomEvent('app:notifications', {
detail: { notifications: messageNotifications },
}));
}
}
}
const notifTotal = Number(data.notifications_total || 0);
if (window.NotificationBus?.setPendingCount) {
window.NotificationBus.setPendingCount(notifTotal);
} else {
try {
window.localStorage.setItem('app_pending_notifications', String(notifTotal));
} catch (e) {}
}
if (window.NotificationBus?.emitPendingCount) {
window.NotificationBus.emitPendingCount(notifTotal);
} else {
window.dispatchEvent(new CustomEvent('app:pendingNotifications', {
detail: { count: notifTotal },
}));
}
if (Array.isArray(data.notifications) && data.notifications.length > 0) {
if (window.NotificationBus?.setPendingList) {
window.NotificationBus.setPendingList(data.notifications);
} else {
APP_CONTEXT.pendingNotificationsList = data.notifications;
}
if (window.NotificationBus?.emitPendingList) {
window.NotificationBus.emitPendingList(data.notifications);
} else {
window.dispatchEvent(new CustomEvent('app:pendingNotificationsList', {
detail: { notifications: data.notifications },
}));
}
}
if (notifTotal > 0) {
const lastNotif = data.notifications?.[data.notifications.length - 1];
const notifFallback = "{{ 'header.notifications.fallback'|trans }}";
const title = lastNotif?.sourceUser?.username || notifFallback;
const content = lastNotif?.message || notifFallback;
const redirectUrl = lastNotif?.redirectUrl || '/conversation';
const showToast = () => {
if (APP_CONTEXT.toast?.setContent) {
APP_CONTEXT.toast.setTitle(title);
APP_CONTEXT.toast.setContent(content);
APP_CONTEXT.toast.setRedirectUrl(redirectUrl);
APP_CONTEXT.toast.setShow(true);
}
};
if (toastScheduled) {
setTimeout(showToast, 1200);
} else {
showToast();
}
}
} catch (e) {
console.warn('Unable to fetch pending notifications', e);
}
}
/**
* Presence heartbeat (online/offline) via SSE.
*/
APP_CONTEXT.presence = APP_CONTEXT.presence || {};
APP_CONTEXT.presence.intervalMs = 30000;
APP_CONTEXT.location = APP_CONTEXT.location || {};
APP_CONTEXT.location.optIn = !!APP_CONTEXT.APP_USER?.locationOptIn;
APP_CONTEXT.location.getCurrent = function() {
return new Promise((resolve) => {
if (!APP_CONTEXT.location.optIn || !navigator.geolocation) {
return resolve(null);
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy,
}),
() => resolve(null),
{
enableHighAccuracy: false,
timeout: 2000,
maximumAge: 60000,
}
);
});
};
APP_CONTEXT.presence.send = async function(isOnline) {
const url = isOnline ? '/user/heart-beat/actif' : '/user/heart-beat/inactif';
if (!isOnline) {
fetch(url, { credentials: 'include', keepalive: true, priority: 'low' }).catch(() => {});
return;
}
const coords = await APP_CONTEXT.location.getCurrent();
const payload = coords ? JSON.stringify(coords) : '';
fetch(url, {
method: 'POST',
credentials: 'include',
keepalive: true,
priority: 'low',
headers: payload ? { 'Content-Type': 'application/json' } : undefined,
body: payload || undefined,
}).catch(() => {});
};
APP_CONTEXT.presence.ping = function() {
APP_CONTEXT.presence.send(true);
};
APP_CONTEXT.presence.start = function() {
const boot = () => {
APP_CONTEXT.presence.send(true);
if (!APP_CONTEXT.presence._interval) {
APP_CONTEXT.presence._interval = setInterval(APP_CONTEXT.presence.ping, APP_CONTEXT.presence.intervalMs);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
APP_CONTEXT.presence.send(true);
}
});
window.addEventListener('pagehide', () => {
APP_CONTEXT.presence.send(false, true);
});
};
if (window.requestIdleCallback) {
window.requestIdleCallback(boot, { timeout: 1500 });
} else {
setTimeout(boot, 300);
}
};
APP_CONTEXT.presence.start();
// déclenchement immédiat
APP_CONTEXT.fetchPendingNotifications();
</script>
{% endif %}