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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user