conversation scripts and style separation
This commit is contained in:
94
static/css/conversation.css
Normal file
94
static/css/conversation.css
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.badge-creator {
|
||||||
|
background-color: #d1f2eb;
|
||||||
|
color: #117a65;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #c82333;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member:hover {
|
||||||
|
background: #f5c6cb;
|
||||||
|
color: #a71d2a;
|
||||||
|
}
|
||||||
|
/* Chat bubble styles */
|
||||||
|
.chat-messages {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
max-width: 80%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.message.sent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.message.received {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.message.sent .message-content {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-top-right-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.message.received .message-content {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
color: #1c1e21;
|
||||||
|
border-top-left-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.message-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.message.sent .message-info {
|
||||||
|
text-align: right;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.message.received .message-info {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #65676b;
|
||||||
|
}
|
||||||
|
.message.sent .message-info .text-muted {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
.message.received .message-info .text-muted {
|
||||||
|
color: #65676b !important;
|
||||||
|
}
|
||||||
|
.attachment {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.message.sent .attachment .btn {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.message.sent .attachment .btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.message.received .attachment .btn {
|
||||||
|
background-color: #e4e6eb;
|
||||||
|
color: #1c1e21;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.message.received .attachment .btn:hover {
|
||||||
|
background-color: #d8dadf;
|
||||||
|
}
|
||||||
151
static/js/chat-manager.js
Normal file
151
static/js/chat-manager.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// Global state and socket management
|
||||||
|
if (typeof window.ChatManager === 'undefined') {
|
||||||
|
window.ChatManager = (function() {
|
||||||
|
let instance = null;
|
||||||
|
let socket = null;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
addedMessageIds: new Set(),
|
||||||
|
messageQueue: new Set(),
|
||||||
|
connectionState: {
|
||||||
|
hasJoined: false,
|
||||||
|
isConnected: false,
|
||||||
|
lastMessageId: null,
|
||||||
|
connectionAttempts: 0,
|
||||||
|
socketId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function init(conversationId) {
|
||||||
|
if (instance) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize message IDs from existing messages
|
||||||
|
$('.message').each(function() {
|
||||||
|
const messageId = $(this).data('message-id');
|
||||||
|
if (messageId) {
|
||||||
|
state.addedMessageIds.add(messageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create socket instance
|
||||||
|
socket = io(window.location.origin, {
|
||||||
|
path: '/socket.io/',
|
||||||
|
transports: ['polling', 'websocket'],
|
||||||
|
upgrade: true,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
timeout: 20000,
|
||||||
|
autoConnect: true,
|
||||||
|
forceNew: true,
|
||||||
|
multiplex: false,
|
||||||
|
pingTimeout: 60000,
|
||||||
|
pingInterval: 25000,
|
||||||
|
upgradeTimeout: 10000,
|
||||||
|
rememberUpgrade: true,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
extraHeaders: {
|
||||||
|
'X-Forwarded-Proto': 'https'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up socket event handlers
|
||||||
|
socket.on('connect', function() {
|
||||||
|
state.connectionState.isConnected = true;
|
||||||
|
state.connectionState.connectionAttempts++;
|
||||||
|
state.connectionState.socketId = socket.id;
|
||||||
|
console.log('Socket connected:', {
|
||||||
|
attempt: state.connectionState.connectionAttempts,
|
||||||
|
socketId: socket.id,
|
||||||
|
existingSocketId: state.connectionState.socketId,
|
||||||
|
transport: socket.io.engine.transport.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always rejoin the room on connect
|
||||||
|
console.log('Joining conversation room:', conversationId);
|
||||||
|
socket.emit('join_conversation', {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
state.connectionState.hasJoined = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', function(reason) {
|
||||||
|
console.log('Disconnected from conversation:', {
|
||||||
|
reason: reason,
|
||||||
|
socketId: socket.id,
|
||||||
|
connectionState: state.connectionState,
|
||||||
|
transport: socket.io.engine?.transport?.name
|
||||||
|
});
|
||||||
|
state.connectionState.isConnected = false;
|
||||||
|
state.connectionState.socketId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect_error', function(error) {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
// Try to reconnect with polling if websocket fails
|
||||||
|
if (socket.io.engine?.transport?.name === 'websocket') {
|
||||||
|
console.log('WebSocket failed, falling back to polling');
|
||||||
|
socket.io.opts.transports = ['polling'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function(error) {
|
||||||
|
console.error('Socket error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add heartbeat to keep connection alive
|
||||||
|
setInterval(function() {
|
||||||
|
if (socket.connected) {
|
||||||
|
socket.emit('heartbeat', {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
socketId: socket.id,
|
||||||
|
transport: socket.io.engine.transport.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
// Handle transport upgrade
|
||||||
|
socket.io.engine.on('upgrade', function() {
|
||||||
|
console.log('Transport upgraded to:', socket.io.engine.transport.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.io.engine.on('upgradeError', function(err) {
|
||||||
|
console.error('Transport upgrade error:', err);
|
||||||
|
// Fall back to polling
|
||||||
|
socket.io.opts.transports = ['polling'];
|
||||||
|
});
|
||||||
|
|
||||||
|
instance = {
|
||||||
|
socket: socket,
|
||||||
|
state: state,
|
||||||
|
cleanup: cleanup
|
||||||
|
};
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
console.log('Cleaning up socket connection');
|
||||||
|
if (socket) {
|
||||||
|
socket.off('new_message');
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getInstance: function(conversationId) {
|
||||||
|
if (!instance) {
|
||||||
|
instance = init(conversationId);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
223
static/js/conversation.js
Normal file
223
static/js/conversation.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// Initialize chat when document is ready
|
||||||
|
$(document).ready(function() {
|
||||||
|
const conversationId = window.conversationId; // Set this in the template
|
||||||
|
const currentUserId = window.currentUserId; // Set this in the template
|
||||||
|
const chat = ChatManager.getInstance(conversationId);
|
||||||
|
const socket = chat.socket;
|
||||||
|
const state = chat.state;
|
||||||
|
|
||||||
|
console.log('Initializing chat for conversation:', conversationId);
|
||||||
|
|
||||||
|
// Join conversation room
|
||||||
|
socket.on('connect', function() {
|
||||||
|
if (!state.connectionState.hasJoined) {
|
||||||
|
console.log('Joining conversation room:', conversationId);
|
||||||
|
socket.emit('join_conversation', {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
state.connectionState.hasJoined = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to append a new message to the chat
|
||||||
|
function appendMessage(message) {
|
||||||
|
const isCurrentUser = message.sender_id === currentUserId;
|
||||||
|
const messageHtml = `
|
||||||
|
<div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}">
|
||||||
|
${!isCurrentUser ? `
|
||||||
|
<img src="${message.sender_avatar}"
|
||||||
|
alt="${message.sender_name}"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
` : ''}
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-info">
|
||||||
|
<span class="fw-medium">${message.sender_name}</span>
|
||||||
|
<span class="text-muted ms-2">${message.created_at}</span>
|
||||||
|
</div>
|
||||||
|
${message.content}
|
||||||
|
${message.attachments && message.attachments.length > 0 ? `
|
||||||
|
<div class="attachments mt-2">
|
||||||
|
${message.attachments.map((attachment, index) => `
|
||||||
|
<div class="attachment mb-1">
|
||||||
|
<a href="${attachment.url}" class="btn btn-sm">
|
||||||
|
<i class="fas fa-paperclip me-1"></i>
|
||||||
|
${attachment.name}
|
||||||
|
<small class="ms-1">(${Math.round(attachment.size / 1024)} KB)</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
${isCurrentUser ? `
|
||||||
|
<img src="${message.sender_avatar}"
|
||||||
|
alt="${message.sender_name}"
|
||||||
|
class="rounded-circle ms-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove the "no messages" placeholder if it exists
|
||||||
|
$('.text-center.text-muted').remove();
|
||||||
|
|
||||||
|
$('#chatMessages').append(messageHtml);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom of chat messages
|
||||||
|
function scrollToBottom() {
|
||||||
|
const chatMessages = document.getElementById('chatMessages');
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Message handling with deduplication and reconnection handling
|
||||||
|
socket.on('new_message', function(message) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const messageKey = `${message.id}-${socket.id}`;
|
||||||
|
|
||||||
|
console.log('Message received:', {
|
||||||
|
id: message.id,
|
||||||
|
timestamp: timestamp,
|
||||||
|
socketId: socket.id,
|
||||||
|
messageKey: messageKey,
|
||||||
|
queueSize: state.messageQueue.size
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.messageQueue.has(messageKey)) {
|
||||||
|
console.log('Message already in queue:', messageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.messageQueue.add(messageKey);
|
||||||
|
|
||||||
|
if (!state.addedMessageIds.has(message.id)) {
|
||||||
|
console.log('Processing new message:', message.id);
|
||||||
|
appendMessage(message);
|
||||||
|
state.connectionState.lastMessageId = message.id;
|
||||||
|
state.addedMessageIds.add(message.id);
|
||||||
|
} else {
|
||||||
|
console.log('Duplicate message detected:', {
|
||||||
|
messageId: message.id,
|
||||||
|
lastMessageId: state.connectionState.lastMessageId,
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up message from queue after processing
|
||||||
|
state.messageQueue.delete(messageKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
$('#fileInput').on('change', function() {
|
||||||
|
const files = Array.from(this.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
const fileNames = files.map(file => file.name).join(', ');
|
||||||
|
$('#selectedFiles').text(files.length > 1 ? `${files.length} files selected: ${fileNames}` : fileNames);
|
||||||
|
} else {
|
||||||
|
$('#selectedFiles').text('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle message form submission with better error handling
|
||||||
|
let isSubmitting = false;
|
||||||
|
$('#messageForm').off('submit').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isSubmitting) {
|
||||||
|
console.log('Message submission already in progress');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageInput = $('#messageInput');
|
||||||
|
const submitButton = $('#messageForm button[type="submit"]');
|
||||||
|
const submitIcon = submitButton.find('.fa-paper-plane');
|
||||||
|
const spinner = submitButton.find('.fa-circle-notch');
|
||||||
|
const message = messageInput.val().trim();
|
||||||
|
const fileInput = $('#fileInput')[0];
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
|
||||||
|
if (!message && files.length === 0) {
|
||||||
|
console.log('Empty message submission attempted');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Submitting message:', {
|
||||||
|
hasText: !!message,
|
||||||
|
fileCount: files.length,
|
||||||
|
socketId: socket.id,
|
||||||
|
connectionState: state.connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
messageInput.prop('disabled', true);
|
||||||
|
submitButton.prop('disabled', true);
|
||||||
|
submitIcon.addClass('d-none');
|
||||||
|
spinner.removeClass('d-none');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', message);
|
||||||
|
formData.append('csrf_token', $('input[name="csrf_token"]').val());
|
||||||
|
formData.append('socket_id', socket.id);
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append(`file_${index}`, file);
|
||||||
|
});
|
||||||
|
formData.append('file_count', files.length);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: window.sendMessageUrl, // Set this in the template
|
||||||
|
method: 'POST',
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
success: function(response) {
|
||||||
|
console.log('Message sent successfully:', {
|
||||||
|
response: response,
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
messageInput.val('');
|
||||||
|
fileInput.value = '';
|
||||||
|
$('#selectedFiles').text('');
|
||||||
|
|
||||||
|
// If socket is disconnected, append message directly
|
||||||
|
if (!state.connectionState.isConnected && response.message) {
|
||||||
|
console.log('Socket disconnected, appending message directly');
|
||||||
|
appendMessage(response.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Message send failed:', response);
|
||||||
|
alert('Failed to send message. Please try again.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error('Failed to send message:', {
|
||||||
|
status: status,
|
||||||
|
error: error,
|
||||||
|
response: xhr.responseText
|
||||||
|
});
|
||||||
|
alert('Failed to send message. Please try again.');
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
messageInput.prop('disabled', false);
|
||||||
|
submitButton.prop('disabled', false);
|
||||||
|
submitIcon.removeClass('d-none');
|
||||||
|
spinner.addClass('d-none');
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
$(window).on('beforeunload', function() {
|
||||||
|
chat.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
static/js/member-management.js
Normal file
76
static/js/member-management.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
$(document).ready(function() {
|
||||||
|
// Initialize Select2 for user selection
|
||||||
|
$('.select2').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle member removal
|
||||||
|
$(document).on('click', '.btn-remove-member', function() {
|
||||||
|
const memberRow = $(this).closest('.member-row');
|
||||||
|
memberRow.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle adding new member
|
||||||
|
$('#addMemberBtn').on('click', function() {
|
||||||
|
const select = $('#user_id');
|
||||||
|
const selectedOption = select.find('option:selected');
|
||||||
|
const selectedValue = select.val();
|
||||||
|
|
||||||
|
if (!selectedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = selectedValue;
|
||||||
|
const userName = selectedOption.text();
|
||||||
|
const userEmail = selectedOption.data('email');
|
||||||
|
const userAvatar = selectedOption.data('avatar');
|
||||||
|
|
||||||
|
// Check if user is already in the list
|
||||||
|
if ($(`.member-row[data-user-id="${userId}"]`).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new member row
|
||||||
|
const memberRow = `
|
||||||
|
<div class="list-group-item d-flex align-items-center member-row" data-user-id="${userId}">
|
||||||
|
<img class="member-avatar" src="${userAvatar}">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold">${userName}</div>
|
||||||
|
<div class="text-muted small">${userEmail}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-remove-member ms-2">
|
||||||
|
<i class="fas fa-user-minus me-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to the list
|
||||||
|
$('#selectedMembersList').append(memberRow);
|
||||||
|
|
||||||
|
// Clear the select and trigger change event
|
||||||
|
select.val(null).trigger('change');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
$('#membersForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Collect all member IDs
|
||||||
|
const memberIds = [];
|
||||||
|
$('.member-row').each(function() {
|
||||||
|
memberIds.push($(this).data('user-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any existing hidden inputs
|
||||||
|
$('input[name="members"]').remove();
|
||||||
|
|
||||||
|
// Add hidden input for each member ID
|
||||||
|
memberIds.forEach(function(id) {
|
||||||
|
$('#membersForm').append(`<input type="hidden" name="members" value="${id}">`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
this.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,102 +6,7 @@
|
|||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||||
<style>
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/conversation.css') }}">
|
||||||
.member-avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
.badge-creator {
|
|
||||||
background-color: #d1f2eb;
|
|
||||||
color: #117a65;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.btn-remove-member {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #c82333;
|
|
||||||
border: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.btn-remove-member:hover {
|
|
||||||
background: #f5c6cb;
|
|
||||||
color: #a71d2a;
|
|
||||||
}
|
|
||||||
/* Chat bubble styles */
|
|
||||||
.chat-messages {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.message-content {
|
|
||||||
position: relative;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
max-width: 80%;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
.message.sent {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.message.received {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
.message.sent .message-content {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border-top-right-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.message.received .message-content {
|
|
||||||
background-color: #f0f2f5;
|
|
||||||
color: #1c1e21;
|
|
||||||
border-top-left-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.message-info {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
.message.sent .message-info {
|
|
||||||
text-align: right;
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
.message.received .message-info {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
color: #65676b;
|
|
||||||
}
|
|
||||||
.message.sent .message-info .text-muted {
|
|
||||||
color: rgba(255, 255, 255, 0.8) !important;
|
|
||||||
}
|
|
||||||
.message.received .message-info .text-muted {
|
|
||||||
color: #65676b !important;
|
|
||||||
}
|
|
||||||
.attachment {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
.message.sent .attachment .btn {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.message.sent .attachment .btn:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
.message.received .attachment .btn {
|
|
||||||
background-color: #e4e6eb;
|
|
||||||
color: #1c1e21;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.message.received .attachment .btn:hover {
|
|
||||||
background-color: #d8dadf;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -290,461 +195,13 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Initialize Select2 for user selection
|
// Set global variables needed by the JavaScript files
|
||||||
$(document).ready(function() {
|
window.conversationId = "{{ conversation.id }}";
|
||||||
$('.select2').select2({
|
window.currentUserId = "{{ current_user.id }}";
|
||||||
theme: 'bootstrap-5',
|
window.sendMessageUrl = "{{ url_for('conversations.send_message', conversation_id=conversation.id) }}";
|
||||||
width: '100%'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle member removal
|
|
||||||
$(document).on('click', '.btn-remove-member', function() {
|
|
||||||
const memberRow = $(this).closest('.member-row');
|
|
||||||
memberRow.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle adding new member
|
|
||||||
$('#addMemberBtn').on('click', function() {
|
|
||||||
const select = $('#user_id');
|
|
||||||
const selectedOption = select.find('option:selected');
|
|
||||||
const selectedValue = select.val();
|
|
||||||
|
|
||||||
if (!selectedValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = selectedValue;
|
|
||||||
const userName = selectedOption.text();
|
|
||||||
const userEmail = selectedOption.data('email');
|
|
||||||
const userAvatar = selectedOption.data('avatar');
|
|
||||||
|
|
||||||
// Check if user is already in the list
|
|
||||||
if ($(`.member-row[data-user-id="${userId}"]`).length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new member row
|
|
||||||
const memberRow = `
|
|
||||||
<div class="list-group-item d-flex align-items-center member-row" data-user-id="${userId}">
|
|
||||||
<img class="member-avatar" src="${userAvatar}">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="fw-bold">${userName}</div>
|
|
||||||
<div class="text-muted small">${userEmail}</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-remove-member ms-2">
|
|
||||||
<i class="fas fa-user-minus me-1"></i>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add to the list
|
|
||||||
$('#selectedMembersList').append(memberRow);
|
|
||||||
|
|
||||||
// Clear the select and trigger change event
|
|
||||||
select.val(null).trigger('change');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
$('#membersForm').on('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Collect all member IDs
|
|
||||||
const memberIds = [];
|
|
||||||
$('.member-row').each(function() {
|
|
||||||
memberIds.push($(this).data('user-id'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove any existing hidden inputs
|
|
||||||
$('input[name="members"]').remove();
|
|
||||||
|
|
||||||
// Add hidden input for each member ID
|
|
||||||
memberIds.forEach(function(id) {
|
|
||||||
$('#membersForm').append(`<input type="hidden" name="members" value="${id}">`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Submit the form
|
|
||||||
this.submit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Global state and socket management
|
|
||||||
if (typeof window.ChatManager === 'undefined') {
|
|
||||||
window.ChatManager = (function() {
|
|
||||||
let instance = null;
|
|
||||||
let socket = null;
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
addedMessageIds: new Set(),
|
|
||||||
messageQueue: new Set(),
|
|
||||||
connectionState: {
|
|
||||||
hasJoined: false,
|
|
||||||
isConnected: false,
|
|
||||||
lastMessageId: null,
|
|
||||||
connectionAttempts: 0,
|
|
||||||
socketId: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store conversation ID globally
|
|
||||||
const conversationId = "{{ conversation.id }}";
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
if (instance) {
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize message IDs from existing messages
|
|
||||||
$('.message').each(function() {
|
|
||||||
const messageId = $(this).data('message-id');
|
|
||||||
if (messageId) {
|
|
||||||
state.addedMessageIds.add(messageId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create socket instance
|
|
||||||
socket = io(window.location.origin, {
|
|
||||||
path: '/socket.io/',
|
|
||||||
transports: ['polling', 'websocket'],
|
|
||||||
upgrade: true,
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionAttempts: Infinity,
|
|
||||||
reconnectionDelay: 1000,
|
|
||||||
reconnectionDelayMax: 5000,
|
|
||||||
timeout: 20000,
|
|
||||||
autoConnect: true,
|
|
||||||
forceNew: true,
|
|
||||||
multiplex: false,
|
|
||||||
pingTimeout: 60000,
|
|
||||||
pingInterval: 25000,
|
|
||||||
upgradeTimeout: 10000,
|
|
||||||
rememberUpgrade: true,
|
|
||||||
rejectUnauthorized: false,
|
|
||||||
extraHeaders: {
|
|
||||||
'X-Forwarded-Proto': 'https'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up socket event handlers
|
|
||||||
socket.on('connect', function() {
|
|
||||||
state.connectionState.isConnected = true;
|
|
||||||
state.connectionState.connectionAttempts++;
|
|
||||||
state.connectionState.socketId = socket.id;
|
|
||||||
console.log('Socket connected:', {
|
|
||||||
attempt: state.connectionState.connectionAttempts,
|
|
||||||
socketId: socket.id,
|
|
||||||
existingSocketId: state.connectionState.socketId,
|
|
||||||
transport: socket.io.engine.transport.name
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always rejoin the room on connect
|
|
||||||
console.log('Joining conversation room:', conversationId);
|
|
||||||
socket.emit('join_conversation', {
|
|
||||||
conversation_id: conversationId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
state.connectionState.hasJoined = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', function(reason) {
|
|
||||||
console.log('Disconnected from conversation:', {
|
|
||||||
reason: reason,
|
|
||||||
socketId: socket.id,
|
|
||||||
connectionState: state.connectionState,
|
|
||||||
transport: socket.io.engine?.transport?.name
|
|
||||||
});
|
|
||||||
state.connectionState.isConnected = false;
|
|
||||||
state.connectionState.socketId = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('connect_error', function(error) {
|
|
||||||
console.error('Connection error:', error);
|
|
||||||
// Try to reconnect with polling if websocket fails
|
|
||||||
if (socket.io.engine?.transport?.name === 'websocket') {
|
|
||||||
console.log('WebSocket failed, falling back to polling');
|
|
||||||
socket.io.opts.transports = ['polling'];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', function(error) {
|
|
||||||
console.error('Socket error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add heartbeat to keep connection alive
|
|
||||||
setInterval(function() {
|
|
||||||
if (socket.connected) {
|
|
||||||
socket.emit('heartbeat', {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
socketId: socket.id,
|
|
||||||
transport: socket.io.engine.transport.name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
// Handle transport upgrade
|
|
||||||
socket.io.engine.on('upgrade', function() {
|
|
||||||
console.log('Transport upgraded to:', socket.io.engine.transport.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.io.engine.on('upgradeError', function(err) {
|
|
||||||
console.error('Transport upgrade error:', err);
|
|
||||||
// Fall back to polling
|
|
||||||
socket.io.opts.transports = ['polling'];
|
|
||||||
});
|
|
||||||
|
|
||||||
instance = {
|
|
||||||
socket: socket,
|
|
||||||
state: state,
|
|
||||||
cleanup: cleanup
|
|
||||||
};
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
console.log('Cleaning up socket connection');
|
|
||||||
if (socket) {
|
|
||||||
socket.off('new_message');
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
}
|
|
||||||
instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getInstance: function() {
|
|
||||||
if (!instance) {
|
|
||||||
instance = init();
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize chat when document is ready
|
|
||||||
$(document).ready(function() {
|
|
||||||
const chat = ChatManager.getInstance();
|
|
||||||
const socket = chat.socket;
|
|
||||||
const state = chat.state;
|
|
||||||
const conversationId = "{{ conversation.id }}";
|
|
||||||
|
|
||||||
console.log('Initializing chat for conversation:', conversationId);
|
|
||||||
|
|
||||||
// Join conversation room
|
|
||||||
socket.on('connect', function() {
|
|
||||||
if (!state.connectionState.hasJoined) {
|
|
||||||
console.log('Joining conversation room:', conversationId);
|
|
||||||
socket.emit('join_conversation', {
|
|
||||||
conversation_id: conversationId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
state.connectionState.hasJoined = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to append a new message to the chat
|
|
||||||
function appendMessage(message) {
|
|
||||||
const currentUserId = "{{ current_user.id }}";
|
|
||||||
const isCurrentUser = message.sender_id === currentUserId;
|
|
||||||
const messageHtml = `
|
|
||||||
<div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}">
|
|
||||||
${!isCurrentUser ? `
|
|
||||||
<img src="${message.sender_avatar}"
|
|
||||||
alt="${message.sender_name}"
|
|
||||||
class="rounded-circle me-2"
|
|
||||||
style="width: 40px; height: 40px; object-fit: cover;">
|
|
||||||
` : ''}
|
|
||||||
<div class="message-content">
|
|
||||||
<div class="message-info">
|
|
||||||
<span class="fw-medium">${message.sender_name}</span>
|
|
||||||
<span class="text-muted ms-2">${message.created_at}</span>
|
|
||||||
</div>
|
|
||||||
${message.content}
|
|
||||||
${message.attachments && message.attachments.length > 0 ? `
|
|
||||||
<div class="attachments mt-2">
|
|
||||||
${message.attachments.map((attachment, index) => `
|
|
||||||
<div class="attachment mb-1">
|
|
||||||
<a href="${attachment.url}" class="btn btn-sm">
|
|
||||||
<i class="fas fa-paperclip me-1"></i>
|
|
||||||
${attachment.name}
|
|
||||||
<small class="ms-1">(${Math.round(attachment.size / 1024)} KB)</small>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
${isCurrentUser ? `
|
|
||||||
<img src="${message.sender_avatar}"
|
|
||||||
alt="${message.sender_name}"
|
|
||||||
class="rounded-circle ms-2"
|
|
||||||
style="width: 40px; height: 40px; object-fit: cover;">
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Remove the "no messages" placeholder if it exists
|
|
||||||
$('.text-center.text-muted').remove();
|
|
||||||
|
|
||||||
$('#chatMessages').append(messageHtml);
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to bottom of chat messages
|
|
||||||
function scrollToBottom() {
|
|
||||||
const chatMessages = document.getElementById('chatMessages');
|
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
||||||
}
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
// Message handling with deduplication and reconnection handling
|
|
||||||
socket.on('new_message', function(message) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const messageKey = `${message.id}-${socket.id}`;
|
|
||||||
|
|
||||||
console.log('Message received:', {
|
|
||||||
id: message.id,
|
|
||||||
timestamp: timestamp,
|
|
||||||
socketId: socket.id,
|
|
||||||
messageKey: messageKey,
|
|
||||||
queueSize: state.messageQueue.size
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.messageQueue.has(messageKey)) {
|
|
||||||
console.log('Message already in queue:', messageKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.messageQueue.add(messageKey);
|
|
||||||
|
|
||||||
if (!state.addedMessageIds.has(message.id)) {
|
|
||||||
console.log('Processing new message:', message.id);
|
|
||||||
appendMessage(message);
|
|
||||||
state.connectionState.lastMessageId = message.id;
|
|
||||||
state.addedMessageIds.add(message.id);
|
|
||||||
} else {
|
|
||||||
console.log('Duplicate message detected:', {
|
|
||||||
messageId: message.id,
|
|
||||||
lastMessageId: state.connectionState.lastMessageId,
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up message from queue after processing
|
|
||||||
state.messageQueue.delete(messageKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle file selection
|
|
||||||
$('#fileInput').on('change', function() {
|
|
||||||
const files = Array.from(this.files);
|
|
||||||
if (files.length > 0) {
|
|
||||||
const fileNames = files.map(file => file.name).join(', ');
|
|
||||||
$('#selectedFiles').text(files.length > 1 ? `${files.length} files selected: ${fileNames}` : fileNames);
|
|
||||||
} else {
|
|
||||||
$('#selectedFiles').text('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle message form submission with better error handling
|
|
||||||
let isSubmitting = false;
|
|
||||||
$('#messageForm').off('submit').on('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (isSubmitting) {
|
|
||||||
console.log('Message submission already in progress');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageInput = $('#messageInput');
|
|
||||||
const submitButton = $('#messageForm button[type="submit"]');
|
|
||||||
const submitIcon = submitButton.find('.fa-paper-plane');
|
|
||||||
const spinner = submitButton.find('.fa-circle-notch');
|
|
||||||
const message = messageInput.val().trim();
|
|
||||||
const fileInput = $('#fileInput')[0];
|
|
||||||
const files = Array.from(fileInput.files);
|
|
||||||
|
|
||||||
if (!message && files.length === 0) {
|
|
||||||
console.log('Empty message submission attempted');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Submitting message:', {
|
|
||||||
hasText: !!message,
|
|
||||||
fileCount: files.length,
|
|
||||||
socketId: socket.id,
|
|
||||||
connectionState: state.connectionState
|
|
||||||
});
|
|
||||||
|
|
||||||
isSubmitting = true;
|
|
||||||
messageInput.prop('disabled', true);
|
|
||||||
submitButton.prop('disabled', true);
|
|
||||||
submitIcon.addClass('d-none');
|
|
||||||
spinner.removeClass('d-none');
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('message', message);
|
|
||||||
formData.append('csrf_token', $('input[name="csrf_token"]').val());
|
|
||||||
formData.append('socket_id', socket.id);
|
|
||||||
|
|
||||||
files.forEach((file, index) => {
|
|
||||||
formData.append(`file_${index}`, file);
|
|
||||||
});
|
|
||||||
formData.append('file_count', files.length);
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: "{{ url_for('conversations.send_message', conversation_id=conversation.id) }}",
|
|
||||||
method: 'POST',
|
|
||||||
data: formData,
|
|
||||||
processData: false,
|
|
||||||
contentType: false,
|
|
||||||
success: function(response) {
|
|
||||||
console.log('Message sent successfully:', {
|
|
||||||
response: response,
|
|
||||||
socketId: socket.id
|
|
||||||
});
|
|
||||||
if (response.success) {
|
|
||||||
messageInput.val('');
|
|
||||||
fileInput.value = '';
|
|
||||||
$('#selectedFiles').text('');
|
|
||||||
|
|
||||||
// If socket is disconnected, append message directly
|
|
||||||
if (!state.connectionState.isConnected && response.message) {
|
|
||||||
console.log('Socket disconnected, appending message directly');
|
|
||||||
appendMessage(response.message);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Message send failed:', response);
|
|
||||||
alert('Failed to send message. Please try again.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function(xhr, status, error) {
|
|
||||||
console.error('Failed to send message:', {
|
|
||||||
status: status,
|
|
||||||
error: error,
|
|
||||||
response: xhr.responseText
|
|
||||||
});
|
|
||||||
alert('Failed to send message. Please try again.');
|
|
||||||
},
|
|
||||||
complete: function() {
|
|
||||||
messageInput.prop('disabled', false);
|
|
||||||
submitButton.prop('disabled', false);
|
|
||||||
submitIcon.removeClass('d-none');
|
|
||||||
spinner.addClass('d-none');
|
|
||||||
isSubmitting = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up on page unload
|
|
||||||
$(window).on('beforeunload', function() {
|
|
||||||
chat.cleanup();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/chat-manager.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/conversation.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/member-management.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
Reference in New Issue
Block a user