Added messaging functions
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
style="height: 40px; margin-right: 10px;">
|
||||
{% endif %}
|
||||
{% if site_settings.company_name %}
|
||||
DocuPulse for {% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
|
||||
DocuPulse for:{% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
|
||||
{% else %}
|
||||
DocuPulse
|
||||
{% endif %}
|
||||
@@ -85,6 +85,11 @@
|
||||
<i class="fas fa-door-open"></i> Rooms
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'conversations.conversations' %}active{% endif %}" href="{{ url_for('conversations.conversations') }}">
|
||||
<i class="fas fa-comments"></i> Conversations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if current_user.is_admin %}
|
||||
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
||||
|
||||
685
templates/conversations/conversation.html
Normal file
685
templates/conversations/conversation.html
Normal file
@@ -0,0 +1,685 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% from "components/header.html" import header %}
|
||||
|
||||
{% block title %}{{ conversation.name }} - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||
|
||||
{% 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-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
.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 %}
|
||||
|
||||
{% block content %}
|
||||
{{ header(
|
||||
title=conversation.name,
|
||||
description=conversation.description or "No description",
|
||||
button_text="Back to Conversations",
|
||||
button_url=url_for('conversations.conversations'),
|
||||
icon="fa-comments",
|
||||
button_class="btn-secondary",
|
||||
button_icon="fa-arrow-left"
|
||||
) }}
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<!-- Main Chat Area -->
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="chat-messages" style="min-height: 400px; max-height: 600px; overflow-y: auto;" id="chatMessages">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="message {% if message.user.id == current_user.id %}sent{% else %}received{% endif %}" data-message-id="{{ message.id }}">
|
||||
{% if message.user.id != current_user.id %}
|
||||
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="{{ message.user.username }}"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
{% endif %}
|
||||
<div class="message-content">
|
||||
<div class="message-info">
|
||||
<span class="fw-medium">{{ message.user.username }} {{ message.user.last_name }}</span>
|
||||
<span class="text-muted ms-2">{{ message.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
||||
</div>
|
||||
{{ message.content }}
|
||||
{% if message.attachments %}
|
||||
<div class="attachments mt-2">
|
||||
{% for attachment in message.attachments %}
|
||||
<div class="attachment mb-1">
|
||||
<a href="{{ url_for('conversations.download_attachment', message_id=message.id, attachment_index=loop.index0) }}" class="btn btn-sm">
|
||||
<i class="fas fa-paperclip me-1"></i>
|
||||
{{ attachment.name }}
|
||||
<small class="ms-1">({{ (attachment.size / 1024)|round|int }} KB)</small>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if message.user.id == current_user.id %}
|
||||
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="{{ message.user.username }}"
|
||||
class="rounded-circle ms-2"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-comments fa-3x mb-3"></i>
|
||||
<p>No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top">
|
||||
<form id="messageForm" class="d-flex flex-column gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="text" class="form-control" id="messageInput" placeholder="Type your message..." required style="min-width:0; height: 38px;">
|
||||
<label for="fileInput" class="btn btn-outline-secondary btn-sm mb-0 d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
|
||||
<i class="fas fa-paperclip"></i>
|
||||
</label>
|
||||
<input type="file" id="fileInput" class="d-none" multiple accept=".pdf,.docx,.doc,.txt,.rtf,.odt,.md,.csv,.xlsx,.xls,.ods,.xlsm,.pptx,.ppt,.odp,.jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.tiff,.zip,.rar,.7z,.tar,.gz,.py,.js,.html,.css,.json,.xml,.sql,.sh,.bat,.mp3,.wav,.ogg,.m4a,.flac,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm,.dwg,.dxf,.ai,.psd,.eps,.indd,.eml,.msg,.vcf,.ics">
|
||||
<button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center" style="min-height: 1.2em;">
|
||||
<small id="selectedFiles" class="text-muted"></small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members Sidebar -->
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-users me-2"></i>Members
|
||||
</h5>
|
||||
{% if current_user.is_admin %}
|
||||
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#manageMembersModal">
|
||||
<i class="fas fa-user-edit me-1"></i>Manage Members
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for member in conversation.members %}
|
||||
<div class="list-group-item d-flex align-items-center">
|
||||
<img src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||
alt="{{ member.username }}"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 40px; height: 40px; object-fit: cover;">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-medium">{{ member.username }} {{ member.last_name }}</div>
|
||||
<div class="text-muted small">{{ member.email }}</div>
|
||||
</div>
|
||||
{% if member.id == conversation.created_by %}
|
||||
<span class="badge bg-primary">
|
||||
<i class="fas fa-crown me-1"></i>Creator
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<!-- Manage Members Modal -->
|
||||
<div class="modal fade" id="manageMembersModal" tabindex="-1" aria-labelledby="manageMembersModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="manageMembersModalLabel">Manage Members</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="POST" action="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id, redirect=url_for('conversations.conversation', conversation_id=conversation.id)) }}" id="membersForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">Select User</label>
|
||||
<select class="form-select select2" id="user_id" name="user_id">
|
||||
<option value="">Search for a user...</option>
|
||||
{% for user in all_users %}
|
||||
{% if user.id != current_user.id %}
|
||||
<option value="{{ user.id }}" data-email="{{ user.email }}" data-avatar="{{ url_for('profile_pic', filename=user.profile_picture) if user.profile_picture else url_for('static', filename='default-avatar.png') }}">{{ user.username }} {{ user.last_name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
|
||||
<i class="fas fa-user-plus me-2"></i>Add Member
|
||||
</button>
|
||||
|
||||
<div class="list-group" id="selectedMembersList">
|
||||
<!-- Render all current members -->
|
||||
{% for member in conversation.members %}
|
||||
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ member.id }}">
|
||||
<img class="member-avatar" src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ member.username }} {{ member.last_name }}</div>
|
||||
<div class="text-muted small">{{ member.email }}</div>
|
||||
</div>
|
||||
{% if member.id == conversation.created_by %}
|
||||
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-remove-member ms-2">
|
||||
<i class="fas fa-user-minus me-1"></i>Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" form="membersForm" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.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>
|
||||
// Initialize Select2 for user selection
|
||||
$(document).ready(function() {
|
||||
$('.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();
|
||||
});
|
||||
});
|
||||
|
||||
// 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() {
|
||||
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({
|
||||
transports: ['websocket'],
|
||||
upgrade: false,
|
||||
reconnection: false,
|
||||
debug: true,
|
||||
forceNew: false,
|
||||
multiplex: false
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', function(reason) {
|
||||
console.log('Disconnected from conversation:', {
|
||||
reason: reason,
|
||||
socketId: socket.id,
|
||||
connectionState: state.connectionState
|
||||
});
|
||||
state.connectionState.isConnected = false;
|
||||
state.connectionState.hasJoined = false;
|
||||
state.connectionState.socketId = null;
|
||||
});
|
||||
|
||||
socket.on('error', function(error) {
|
||||
console.error('Socket error:', error);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
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
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
if (!state.connectionState.isConnected) {
|
||||
console.error('Socket not connected, cannot send message');
|
||||
alert('Connection lost. Please refresh the page.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageInput = $('#messageInput');
|
||||
const submitButton = $('#messageForm button[type="submit"]');
|
||||
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);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('message', message);
|
||||
formData.append('csrf_token', $('input[name="csrf_token"]').val());
|
||||
formData.append('socket_id', socket.id);
|
||||
|
||||
// Append each file to the FormData
|
||||
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('');
|
||||
}
|
||||
},
|
||||
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);
|
||||
isSubmitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
$(window).on('beforeunload', function() {
|
||||
chat.cleanup();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
154
templates/conversations/conversations.html
Normal file
154
templates/conversations/conversations.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% from "components/header.html" import header %}
|
||||
|
||||
{% block title %}Conversations - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ header(
|
||||
title="Conversations",
|
||||
description="Manage your conversations and messages",
|
||||
button_text="New Conversation" if current_user.is_admin else "",
|
||||
button_url=url_for('conversations.create_conversation') if current_user.is_admin else "",
|
||||
icon="fa-comments",
|
||||
button_icon="fa-plus"
|
||||
) }}
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<form method="GET" class="d-flex align-items-center w-100 justify-content-between" id="conversationFilterForm" style="gap: 1rem;">
|
||||
<input type="text" name="search" placeholder="Search conversations..." value="{{ search }}" class="form-control flex-grow-1" id="conversationSearchInput" autocomplete="off" style="min-width: 0;" />
|
||||
<button type="button" id="clearConversationsFilter" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200 ms-2 flex-shrink-0"
|
||||
style="background-color: var(--primary-color); border: 1px solid var(--primary-color);"
|
||||
onmouseover="this.style.backgroundColor='var(--primary-light)'"
|
||||
onmouseout="this.style.backgroundColor='var(--primary-color)'">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
{% for conversation in conversations %}
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm hover-shadow">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-0">{{ conversation.name }}</h5>
|
||||
<div class="text-muted small mt-1">Created on {{ conversation.created_at.strftime('%b %d, %Y') }}</div>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark">
|
||||
<i class="fas fa-users"></i> {{ conversation.members|length }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text text-muted">{{ conversation.description or 'No description' }}</p>
|
||||
<div class="d-flex align-items-center mt-3">
|
||||
<img src="{{ url_for('profile_pic', filename=conversation.creator.profile_picture) if conversation.creator and conversation.creator.profile_picture else url_for('static', filename='img/default-avatar.png') }}"
|
||||
alt="Creator"
|
||||
class="rounded-circle me-2"
|
||||
style="width: 32px; height: 32px; object-fit: cover;">
|
||||
<div>
|
||||
<small class="text-muted">Created by</small>
|
||||
<div class="fw-medium">{{ conversation.creator.username }} {{ conversation.creator.last_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top-0">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('conversations.conversation', conversation_id=conversation.id) }}" class="btn btn-primary flex-grow-1">
|
||||
<i class="fas fa-comments me-2"></i>Open Conversation
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="conversationActions{{ conversation.id }}" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="conversationActions{{ conversation.id }}">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id) }}">
|
||||
<i class="fas fa-edit me-2"></i>Edit Conversation
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<button type="button" class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteConversationModal{{ conversation.id }}">
|
||||
<i class="fas fa-trash me-2"></i>Delete Conversation
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_admin %}
|
||||
<!-- Delete Conversation Modal -->
|
||||
<div class="modal fade" id="deleteConversationModal{{ conversation.id }}" tabindex="-1" aria-labelledby="deleteConversationModalLabel{{ conversation.id }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteConversationModalLabel{{ conversation.id }}">Move to Trash</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<i class="fas fa-trash text-danger" style="font-size: 2rem;"></i>
|
||||
<div>
|
||||
<h6 class="mb-1">Move to Trash</h6>
|
||||
<p class="text-muted mb-0" id="deleteConversationName{{ conversation.id }}">{{ conversation.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
This item will be moved to trash. You can restore it from the trash page within 30 days.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form action="{{ url_for('conversations.delete_conversation', conversation_id=conversation.id) }}" method="POST" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>Delete Conversation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('conversationSearchInput');
|
||||
const form = document.getElementById('conversationFilterForm');
|
||||
if (searchInput && form) {
|
||||
searchInput.addEventListener('input', debounce(function() {
|
||||
form.submit();
|
||||
}, 300));
|
||||
}
|
||||
// Clear button logic
|
||||
const clearBtn = document.getElementById('clearConversationsFilter');
|
||||
if (clearBtn && searchInput) {
|
||||
clearBtn.addEventListener('click', function() {
|
||||
searchInput.value = '';
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
238
templates/conversations/create_conversation.html
Normal file
238
templates/conversations/create_conversation.html
Normal file
@@ -0,0 +1,238 @@
|
||||
{% extends "common/base.html" %}
|
||||
{% from "components/header.html" import header %}
|
||||
|
||||
{% block title %}Create Conversation - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||
|
||||
{% 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-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
<style>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ header(
|
||||
title=("Edit Conversation" if edit_mode else "Create New Conversation"),
|
||||
description=("Update conversation details" if edit_mode else "Start a new conversation with your team"),
|
||||
button_text="Cancel",
|
||||
button_url=url_for('conversations.conversations'),
|
||||
icon="fa-edit" if edit_mode else "fa-comments",
|
||||
button_class="btn-secondary",
|
||||
button_icon="fa-times"
|
||||
) }}
|
||||
|
||||
<!-- Alert Modal -->
|
||||
<div id="alertModal" class="modal fade" tabindex="-1" aria-labelledby="alertModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="alertModalLabel">Notification</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="fas fa-exclamation-circle text-warning" style="font-size: 2rem;"></i>
|
||||
<div id="alertModalMessage"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<form method="POST" id="conversationForm">
|
||||
{{ form.csrf_token }}
|
||||
<div class="mb-3">
|
||||
{{ form.name.label(class="form-label") }}
|
||||
{{ form.name(class="form-control", placeholder="Enter conversation name") }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.name.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
|
||||
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger">
|
||||
{% for error in form.description.errors %}
|
||||
<small>{{ error }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ "Edit Conversation" if edit_mode else "Create Conversation" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">Add Members</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">Select User</label>
|
||||
<select class="form-select select2" id="user_id" name="user_id">
|
||||
<option value="">Search for a user...</option>
|
||||
{% for user in form.members.choices %}
|
||||
{% if user[0] != current_user.id %}
|
||||
<option value="{{ user[0] }}" data-email="{{ user[2] if user|length > 2 else '' }}" data-avatar="{{ url_for('profile_pic', filename=user[3]) if user|length > 3 and user[3] else url_for('static', filename='default-avatar.png') }}">{{ user[1] }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
|
||||
<i class="fas fa-user-plus me-2"></i>Add Member
|
||||
</button>
|
||||
|
||||
<div class="list-group" id="selectedMembersList">
|
||||
<!-- Render creator row server-side -->
|
||||
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ current_user.id }}">
|
||||
<img class="member-avatar" src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold">{{ current_user.username }} {{ current_user.last_name }}</div>
|
||||
<div class="text-muted small">{{ current_user.email }}</div>
|
||||
</div>
|
||||
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('.select2').select2({
|
||||
theme: 'bootstrap-5',
|
||||
width: '100%',
|
||||
placeholder: 'Search for a user...',
|
||||
allowClear: true
|
||||
});
|
||||
|
||||
// Keep track of added members
|
||||
var addedMembers = new Set();
|
||||
var creatorId = '{{ current_user.id }}';
|
||||
addedMembers.add(creatorId);
|
||||
|
||||
// Function to show alert modal
|
||||
function showAlert(message) {
|
||||
$('#alertModalMessage').text(message);
|
||||
var alertModal = new bootstrap.Modal(document.getElementById('alertModal'));
|
||||
alertModal.show();
|
||||
}
|
||||
|
||||
// Handle Add Member button click
|
||||
$('#addMemberBtn').click(function() {
|
||||
var selectedUserId = $('#user_id').val();
|
||||
var selectedUserName = $('#user_id option:selected').text();
|
||||
var selectedUserEmail = $('#user_id option:selected').data('email') || '';
|
||||
var selectedUserAvatar = $('#user_id option:selected').data('avatar') || "{{ url_for('static', filename='default-avatar.png') }}";
|
||||
|
||||
if (!selectedUserId) {
|
||||
showAlert('Please select a user to add.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedMembers.has(selectedUserId)) {
|
||||
showAlert('This user has already been added.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to the set of added members
|
||||
addedMembers.add(selectedUserId);
|
||||
|
||||
// Disable the option in the dropdown
|
||||
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', true);
|
||||
$('#user_id').val(null).trigger('change');
|
||||
|
||||
// Create the member list item
|
||||
var memberItem = $('<div class="list-group-item d-flex align-items-center member-row" data-user-id="' + selectedUserId + '">')
|
||||
.append($('<img class="member-avatar">').attr('src', selectedUserAvatar))
|
||||
.append($('<div class="flex-grow-1">')
|
||||
.append($('<div class="fw-bold">').text(selectedUserName))
|
||||
.append($('<div class="text-muted small">').text(selectedUserEmail))
|
||||
)
|
||||
.append($('<button type="button" class="btn btn-remove-member ms-2">')
|
||||
.append($('<i class="fas fa-user-minus me-1"></i>'))
|
||||
.append('Remove')
|
||||
.click(function() {
|
||||
$(this).closest('.list-group-item').remove();
|
||||
addedMembers.delete(selectedUserId);
|
||||
// Re-enable the option in the dropdown
|
||||
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', false);
|
||||
$('#user_id').trigger('change');
|
||||
updateHiddenInputs();
|
||||
})
|
||||
);
|
||||
|
||||
// Add to the list
|
||||
$('#selectedMembersList').append(memberItem);
|
||||
|
||||
// Update hidden inputs
|
||||
updateHiddenInputs();
|
||||
});
|
||||
|
||||
function updateHiddenInputs() {
|
||||
// Remove any existing members inputs
|
||||
$('#conversationForm input[name="members"]').remove();
|
||||
|
||||
// Add new hidden inputs for each member
|
||||
addedMembers.forEach(function(memberId) {
|
||||
var input = $('<input>')
|
||||
.attr('type', 'hidden')
|
||||
.attr('name', 'members')
|
||||
.val(memberId);
|
||||
$('#conversationForm').append(input);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize hidden inputs
|
||||
updateHiddenInputs();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user