Autosave: 20260215-105502

This commit is contained in:
Flatlogic Bot 2026-02-15 10:55:02 +00:00
parent ef520f4259
commit c49abcc049
17 changed files with 1865 additions and 166 deletions

26
api/pexels.php Normal file
View File

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
require_once __DIR__.'/../includes/pexels.php';
$action = $_GET['action'] ?? 'search';
if ($action === 'search') {
$q = $_GET['query'] ?? 'avatar';
$url = 'https://api.pexels.com/v1/search?query=' . urlencode($q) . '&per_page=12&page=1';
$data = pexels_get($url);
if (!$data) {
echo json_encode(['error' => 'Failed to fetch images']);
exit;
}
$results = [];
foreach ($data['photos'] as $photo) {
$results[] = [
'id' => $photo['id'],
'url' => $photo['src']['medium'],
'photographer' => $photo['photographer']
];
}
echo json_encode($results);
exit;
}

67
api_v1_dms.php Normal file
View File

@ -0,0 +1,67 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
$current_user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$target_user_id = $_POST['user_id'] ?? 0;
if ($target_user_id == $current_user_id) {
echo json_encode(['success' => false, 'error' => 'You cannot message yourself']);
exit;
}
try {
// Check if DM channel already exists between these two users
$stmt = db()->prepare("
SELECT c.id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id = ?
");
$stmt->execute([$current_user_id, $target_user_id]);
$existing = $stmt->fetch();
if ($existing) {
echo json_encode(['success' => true, 'channel_id' => $existing['id']]);
exit;
}
// Create new DM channel
$stmt = db()->prepare("INSERT INTO channels (server_id, name, type) VALUES (NULL, 'dm', 'dm')");
$stmt->execute();
$channel_id = db()->lastInsertId();
// Add both users to the channel
$stmt = db()->prepare("INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?), (?, ?)");
$stmt->execute([$channel_id, $current_user_id, $channel_id, $target_user_id]);
echo json_encode(['success' => true, 'channel_id' => $channel_id]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Fetch all DM channels for current user
try {
$stmt = db()->prepare("
SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
JOIN users u ON cm2.user_id = u.id
WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id != ?
");
$stmt->execute([$current_user_id, $current_user_id]);
$dms = $stmt->fetchAll();
echo json_encode(['success' => true, 'dms' => $dms]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}

View File

@ -1,6 +1,7 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
require_once 'includes/opengraph.php';
// Check for Bot token in headers
$headers = getallheaders();
@ -29,28 +30,112 @@ if ($bot_token) {
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['id'] ?? 0;
$content = $data['content'] ?? '';
if (empty($content)) {
echo json_encode(['success' => false, 'error' => 'Empty content']);
if (empty($content)) {
echo json_encode(['success' => false, 'error' => 'Content cannot be empty']);
exit;
}
try {
$stmt = db()->prepare("UPDATE messages SET content = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$content, $message_id, $user_id]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['id'] ?? 0;
try {
$stmt = db()->prepare("DELETE FROM messages WHERE id = ? AND user_id = ?");
$stmt->execute([$message_id, $user_id]);
if ($stmt->rowCount() > 0) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Message not found or unauthorized']);
}
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
$content = '';
$channel_id = 0;
$attachment_url = null;
if (strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') !== false) {
$data = json_decode(file_get_contents('php://input'), true);
$content = $data['content'] ?? '';
$channel_id = $data['channel_id'] ?? 0;
} else {
$content = $_POST['content'] ?? '';
$channel_id = $_POST['channel_id'] ?? 0;
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$upload_dir = 'assets/uploads/';
if (!is_dir($upload_dir)) mkdir($upload_dir, 0775, true);
$filename = time() . '_' . basename($_FILES['file']['name']);
$target_file = $upload_dir . $filename;
if (move_uploaded_file($_FILES['file']['tmp_name'], $target_file)) {
$attachment_url = $target_file;
}
}
}
if (empty($content) && empty($attachment_url)) {
echo json_encode(['success' => false, 'error' => 'Empty content and no attachment']);
exit;
}
$metadata = null;
if (!empty($content)) {
$urls = extractUrls($content);
if (!empty($urls)) {
// Fetch OG data for the first URL
$ogData = fetchOpenGraphData($urls[0]);
if ($ogData) {
$metadata = json_encode($ogData);
}
}
}
try {
$stmt = db()->prepare("INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)");
$stmt->execute([$channel_id, $user_id, $content]);
$stmt = db()->prepare("INSERT INTO messages (channel_id, user_id, content, attachment_url, metadata) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$channel_id, $user_id, $content, $attachment_url, $metadata]);
$last_id = db()->lastInsertId();
// Fetch message with username for the response
$stmt = db()->prepare("SELECT m.*, u.username FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?");
$stmt = db()->prepare("SELECT m.*, u.username, u.avatar_url FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?");
$stmt->execute([$last_id]);
$msg = $stmt->fetch();
echo json_encode([
'success' => true,
'message' => [
'id' => $msg['id'],
'user_id' => $msg['user_id'],
'username' => $msg['username'],
'content' => htmlspecialchars($msg['content']),
'avatar_url' => $msg['avatar_url'],
'content' => $msg['content'],
'attachment_url' => $msg['attachment_url'],
'metadata' => $msg['metadata'] ? json_decode($msg['metadata']) : null,
'time' => date('H:i', strtotime($msg['created_at']))
]
]);

58
api_v1_reactions.php Normal file
View File

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$user_id = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
$message_id = $data['message_id'] ?? 0;
$emoji = $data['emoji'] ?? '';
$action = $data['action'] ?? 'toggle'; // 'toggle', 'add', 'remove'
if (!$message_id || !$emoji) {
echo json_encode(['success' => false, 'error' => 'Missing message_id or emoji']);
exit;
}
try {
if ($action === 'toggle') {
$stmt = db()->prepare("SELECT id FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
if ($stmt->fetch()) {
$stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'removed';
} else {
$stmt = db()->prepare("INSERT INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'added';
}
} elseif ($action === 'add') {
$stmt = db()->prepare("INSERT IGNORE INTO message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'added';
} else {
$stmt = db()->prepare("DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?");
$stmt->execute([$message_id, $user_id, $emoji]);
$res_action = 'removed';
}
// Get updated reactions for this message
$stmt = db()->prepare("SELECT emoji, COUNT(*) as count, GROUP_CONCAT(user_id) as users FROM message_reactions WHERE message_id = ? GROUP BY emoji");
$stmt->execute([$message_id]);
$reactions = $stmt->fetchAll();
echo json_encode([
'success' => true,
'action' => $res_action,
'message_id' => $message_id,
'reactions' => $reactions
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

86
api_v1_roles.php Normal file
View File

@ -0,0 +1,86 @@
<?php
require_once 'auth/session.php';
requireLogin();
$user_id = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true) ?? $_POST;
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$server_id = $_GET['server_id'] ?? 0;
$stmt = db()->prepare("SELECT * FROM roles WHERE server_id = ? ORDER BY position DESC");
$stmt->execute([$server_id]);
echo json_encode(['success' => true, 'roles' => $stmt->fetchAll()]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$server_id = $data['server_id'] ?? 0;
$action = $data['action'] ?? 'create';
// Check if user is owner of server
$stmt = db()->prepare("SELECT owner_id FROM servers WHERE id = ?");
$stmt->execute([$server_id]);
$server = $stmt->fetch();
if (!$server || $server['owner_id'] != $user_id) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
if ($action === 'create') {
$name = $data['name'] ?? 'New Role';
$color = $data['color'] ?? '#99aab5';
$stmt = db()->prepare("INSERT INTO roles (server_id, name, color) VALUES (?, ?, ?)");
$stmt->execute([$server_id, $name, $color]);
echo json_encode(['success' => true, 'role_id' => db()->lastInsertId()]);
} elseif ($action === 'assign') {
$target_user_id = $data['user_id'] ?? 0;
$role_id = $data['role_id'] ?? 0;
$stmt = db()->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$target_user_id, $role_id]);
echo json_encode(['success' => true]);
} elseif ($action === 'unassign') {
$target_user_id = $data['user_id'] ?? 0;
$role_id = $data['role_id'] ?? 0;
$stmt = db()->prepare("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?");
$stmt->execute([$target_user_id, $role_id]);
echo json_encode(['success' => true]);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$role_id = $data['id'] ?? 0;
$name = $data['name'] ?? '';
$color = $data['color'] ?? '';
$permissions = $data['permissions'] ?? null;
// Check server ownership via role
$stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN roles r ON s.id = r.server_id WHERE r.id = ?");
$stmt->execute([$role_id]);
$server = $stmt->fetch();
if ($server && $server['owner_id'] == $user_id) {
$stmt = db()->prepare("UPDATE roles SET name = ?, color = ?, permissions = ? WHERE id = ?");
$stmt->execute([$name, $color, $permissions, $role_id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$role_id = $data['id'] ?? 0;
$stmt = db()->prepare("SELECT s.owner_id FROM servers s JOIN roles r ON s.id = r.server_id WHERE r.id = ?");
$stmt->execute([$role_id]);
$server = $stmt->fetch();
if ($server && $server['owner_id'] == $user_id) {
$stmt = db()->prepare("DELETE FROM roles WHERE id = ?");
$stmt->execute([$role_id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
}
exit;
}

45
api_v1_search.php Normal file
View File

@ -0,0 +1,45 @@
<?php
header('Content-Type: application/json');
require_once 'auth/session.php';
requireLogin();
$user_id = $_SESSION['user_id'];
$query = $_GET['q'] ?? '';
$channel_id = $_GET['channel_id'] ?? 0;
if (empty($query)) {
echo json_encode(['success' => true, 'results' => []]);
exit;
}
try {
$sql = "SELECT m.*, u.username, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.content LIKE ? ";
$params = ["%" . $query . "%"];
if ($channel_id > 0) {
$sql .= " AND m.channel_id = ?";
$params[] = $channel_id;
} else {
// Search in all channels user has access to
$sql .= " AND m.channel_id IN (
SELECT c.id FROM channels c
LEFT JOIN server_members sm ON c.server_id = sm.server_id
LEFT JOIN channel_members cm ON c.id = cm.channel_id
WHERE sm.user_id = ? OR cm.user_id = ?
)";
$params[] = $user_id;
$params[] = $user_id;
}
$sql .= " ORDER BY m.created_at DESC LIMIT 50";
$stmt = db()->prepare($sql);
$stmt->execute($params);
$results = $stmt->fetchAll();
echo json_encode(['success' => true, 'results' => $results]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

View File

@ -22,7 +22,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
if ($action === 'update') {
$server_id = $_POST['server_id'] ?? 0;
$name = $_POST['name'] ?? '';
$icon_url = $_POST['icon_url'] ?? '';
$stmt = db()->prepare("UPDATE servers SET name = ?, icon_url = ? WHERE id = ? AND owner_id = ?");
$stmt->execute([$name, $icon_url, $server_id, $user_id]);
header('Location: index.php?server_id=' . $server_id);
exit;
}
if ($action === 'delete') {
$server_id = $_POST['server_id'] ?? 0;
$stmt = db()->prepare("DELETE FROM servers WHERE id = ? AND owner_id = ?");
$stmt->execute([$server_id, $user_id]);
header('Location: index.php');
exit;
}
$name = $_POST['name'] ?? '';
$icon_url = $_POST['icon_url'] ?? '';
if ($name) {
try {
@ -31,8 +51,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Create server
$invite_code = substr(strtoupper(md5(uniqid())), 0, 8);
$stmt = $db->prepare("INSERT INTO servers (name, owner_id, invite_code) VALUES (?, ?, ?)");
$stmt->execute([$name, $user_id, $invite_code]);
$stmt = $db->prepare("INSERT INTO servers (name, owner_id, invite_code, icon_url) VALUES (?, ?, ?, ?)");
$stmt->execute([$name, $user_id, $invite_code, $icon_url]);
$server_id = $db->lastInsertId();
// Add owner as member

28
api_v1_user.php Normal file
View File

@ -0,0 +1,28 @@
<?php
require_once 'auth/session.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = getCurrentUser();
if (!$user) {
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$username = $_POST['username'] ?? $user['username'];
$avatar_url = $_POST['avatar_url'] ?? $user['avatar_url'];
try {
$stmt = db()->prepare("UPDATE users SET username = ?, avatar_url = ? WHERE id = ?");
$stmt->execute([$username, $avatar_url, $user['id']]);
$_SESSION['username'] = $username; // Update session if stored (though getCurrentUser fetches from DB)
echo json_encode(['success' => true]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
exit;
}
echo json_encode(['success' => false, 'error' => 'Invalid request']);

View File

@ -237,6 +237,12 @@ body {
.message-item {
display: flex;
gap: 16px;
padding: 4px 0;
transition: background-color 0.1s;
}
.message-item:hover {
background-color: rgba(255, 255, 255, 0.02);
}
.message-avatar {
@ -245,6 +251,35 @@ body {
background-color: #4e5058;
border-radius: 50%;
flex-shrink: 0;
background-size: cover;
background-position: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.typing-indicator {
padding: 0 16px 8px 16px;
font-size: 0.75em;
color: var(--text-muted);
height: 20px;
font-style: italic;
}
.avatar-pick {
width: 60px;
height: 60px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.avatar-pick:hover {
border-color: var(--blurple);
}
.avatar-pick.selected {
border-color: var(--blurple);
box-shadow: 0 0 10px var(--blurple);
}
.message-content {
@ -299,8 +334,296 @@ body {
display: none; /* Hidden on mobile/small screens */
}
@media (min-width: 1024px) {
.members-sidebar {
display: block;
/* Reactions */
.message-reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reaction-badge {
background-color: #2b2d31;
border: 1px solid transparent;
border-radius: 8px;
padding: 2px 6px;
font-size: 0.8em;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.1s;
}
.reaction-badge:hover {
border-color: #5865f2;
background-color: #35373c;
}
.reaction-badge.active {
background-color: rgba(88, 101, 242, 0.15);
border-color: #5865f2;
}
.reaction-badge .count {
color: #5865f2;
font-weight: bold;
}
.reaction-badge.active .count {
color: white;
}
.add-reaction-btn {
opacity: 0;
cursor: pointer;
color: var(--text-muted);
font-size: 1.2em;
line-height: 1;
padding: 0 4px;
transition: opacity 0.2s;
}
.message-item:hover .add-reaction-btn,
.message-item:hover .message-actions-menu {
opacity: 1;
}
.message-actions-menu {
opacity: 0;
display: flex;
gap: 8px;
margin-left: auto;
transition: opacity 0.2s;
}
.action-btn {
color: var(--text-muted);
cursor: pointer;
padding: 2px;
}
.action-btn:hover {
color: var(--text-primary);
}
.action-btn.delete:hover {
color: #f23f42;
}
/* Search bar */
.search-container {
margin-left: auto;
position: relative;
width: 200px;
}
.search-input {
background-color: var(--bg-servers);
border: none;
border-radius: 4px;
padding: 4px 8px;
color: var(--text-primary);
font-size: 0.85em;
width: 100%;
}
.search-results-dropdown {
position: absolute;
top: 100%;
right: 0;
width: 300px;
background-color: var(--bg-channels);
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
z-index: 1000;
max-height: 400px;
overflow-y: auto;
display: none;
}
.search-result-item {
padding: 8px;
border-bottom: 1px solid var(--bg-servers);
cursor: pointer;
}
.search-result-item:hover {
background-color: var(--hover);
}
.search-result-author {
font-weight: bold;
font-size: 0.85em;
}
.search-result-text {
font-size: 0.8em;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* DM Specific */
.dm-user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
color: var(--text-muted);
}
.dm-user-item:hover {
background-color: var(--hover);
color: var(--text-primary);
}
.dm-user-item.active {
background-color: var(--active);
color: var(--text-primary);
}
.dm-status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--bg-channels);
}
.dm-status-online { background-color: #23a559; }
.dm-status-offline { background-color: #80848e; }
.emoji-picker {
position: fixed;
background-color: #1e1f22;
border: 1px solid #313338;
border-radius: 8px;
padding: 8px;
display: flex;
gap: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
z-index: 10000;
}
.emoji-picker span {
font-size: 1.5em;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background 0.1s;
}
.emoji-picker span:hover {
background-color: #35373c;
}
/* File Upload */
.upload-btn-label {
margin-right: 12px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
}
.upload-btn-label:hover {
color: var(--text-primary);
}
.message-img-preview {
max-width: 100%;
max-height: 300px;
object-fit: contain;
background-color: #2b2d31;
}
.attachment-link {
transition: background 0.2s;
}
.attachment-link:hover {
background-color: #3f4147 !important;
}
/* Rich Embeds */
.rich-embed {
transition: transform 0.2s;
}
.rich-embed:hover {
transform: scale(1.01);
}
.embed-title {
color: #00a8fc !important;
}
.embed-title:hover {
text-decoration: underline !important;
}
.embed-image img {
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Voice active state */
.voice-item.active {
background-color: rgba(35, 165, 89, 0.1);
color: #23a559 !important;
}
.voice-user {
padding: 2px 4px;
border-radius: 4px;
}
.voice-user .message-avatar {
background-color: var(--bg-servers);
border: 1px solid rgba(255,255,255,0.1);
}
/* Roles Management */
#roles-list .list-group-item:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.nav-tabs .nav-link.active {
border-bottom: 2px solid var(--blurple) !important;
color: white !important;
}
.nav-tabs .nav-link {
font-size: 0.9em;
font-weight: 500;
padding: 12px;
}
.presence-indicator {
box-shadow: 0 0 2px rgba(0,0,0,0.5);
}
/* Mobile & Transitions */
@media (max-width: 768px) {
.servers-sidebar {
width: 60px;
}
.channels-sidebar {
width: 200px;
}
.server-icon {
width: 40px;
height: 40px;
}
}
.message-item {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@ -1,85 +1,478 @@
document.addEventListener('DOMContentLoaded', () => {
const fileUpload = document.getElementById('file-upload');
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const messagesList = document.getElementById('messages-list');
const typingIndicator = document.getElementById('typing-indicator');
// Emoji list for reactions
const EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '✅', '🚀'];
// Scroll to bottom
messagesList.scrollTop = messagesList.scrollHeight;
const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1;
let typingTimeout;
// WebSocket for real-time
let ws;
try {
ws = new WebSocket('ws://' + window.location.hostname + ':8080');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'message') {
const data = JSON.parse(msg.data);
// Simple broadcast, we check if it belongs to current channel
const currentChannel = new URLSearchParams(window.location.search).get('channel_id') || 1;
if (data.channel_id == currentChannel) {
appendMessage(data);
messagesList.scrollTop = messagesList.scrollHeight;
}
let voiceHandler;
function connectWS() {
try {
ws = new WebSocket('ws://' + window.location.hostname + ':8080');
if (typeof VoiceChannel !== 'undefined') {
voiceHandler = new VoiceChannel(ws);
}
};
} catch (e) {
console.warn('WebSocket connection failed, falling back to REST only.');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
// Voice signaling
if (msg.type && msg.type.startsWith('voice_')) {
if (voiceHandler) voiceHandler.handleSignaling(msg);
return;
}
if (msg.type === 'message') {
const data = JSON.parse(msg.data);
if (data.channel_id == currentChannel) {
appendMessage(data);
messagesList.scrollTop = messagesList.scrollHeight;
}
} else if (msg.type === 'typing') {
if (msg.channel_id == currentChannel && msg.user_id != window.currentUserId) {
showTyping(msg.username);
}
} else if (msg.type === 'reaction') {
updateReactionUI(msg.message_id, msg.reactions);
} else if (msg.type === 'message_edit') {
const el = document.querySelector(`.message-item[data-id="${msg.message_id}"] .message-text`);
if (el) el.innerHTML = msg.content.replace(/\n/g, '<br>');
} else if (msg.type === 'message_delete') {
document.querySelector(`.message-item[data-id="${msg.message_id}"]`)?.remove();
} else if (msg.type === 'presence') {
updatePresenceUI(msg.user_id, msg.status);
}
};
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'presence',
user_id: window.currentUserId,
status: 'online'
}));
};
ws.onclose = () => setTimeout(connectWS, 3000);
} catch (e) {
console.warn('WebSocket connection failed.');
}
}
connectWS();
function showTyping(username) {
typingIndicator.textContent = `${username} is typing...`;
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
typingIndicator.textContent = '';
}, 3000);
}
chatInput.addEventListener('input', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'typing',
channel_id: currentChannel,
user_id: window.currentUserId,
username: window.currentUsername
}));
}
});
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const content = chatInput.value.trim();
if (!content) return;
const file = fileUpload.files[0];
if (!content && !file) return;
chatInput.value = '';
const channel_id = new URLSearchParams(window.location.search).get('channel_id') || 1;
const formData = new FormData();
formData.append('content', content);
formData.append('channel_id', currentChannel);
if (file) {
formData.append('file', file);
fileUpload.value = ''; // Clear file input
}
try {
const response = await fetch('api_v1_messages.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: content,
channel_id: channel_id
})
body: formData
});
const result = await response.json();
if (result.success) {
// If WS is connected, we might want to let WS handle the UI update
// But for simplicity, we append here and also send to WS
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'message',
data: JSON.stringify({
...result.message,
channel_id: channel_id
channel_id: currentChannel
})
}));
} else {
appendMessage(result.message);
messagesList.scrollTop = messagesList.scrollHeight;
}
} else {
alert('Error: ' + result.error);
}
} catch (err) {
console.error('Failed to send message:', err);
}
});
// Voice
const voiceHandler = new VoiceChannel(ws);
document.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
const cid = item.dataset.channelId;
voiceHandler.join(cid);
// UI Update
document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
// Handle Reaction Clicks
document.addEventListener('click', (e) => {
const badge = e.target.closest('.reaction-badge');
if (badge) {
const msgId = badge.parentElement.dataset.messageId;
const emoji = badge.dataset.emoji;
toggleReaction(msgId, emoji);
return;
}
const addBtn = e.target.closest('.add-reaction-btn');
if (addBtn) {
const msgId = addBtn.parentElement.dataset.messageId;
showEmojiPicker(addBtn, msgId);
return;
}
// Close picker if click outside
if (!e.target.closest('.emoji-picker')) {
const picker = document.querySelector('.emoji-picker');
if (picker) picker.remove();
}
});
async function toggleReaction(messageId, emoji) {
try {
const resp = await fetch('api_v1_reactions.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId, emoji: emoji })
});
const result = await resp.json();
if (result.success) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'reaction',
message_id: messageId,
reactions: result.reactions
}));
}
updateReactionUI(messageId, result.reactions);
}
} catch (e) { console.error(e); }
}
function showEmojiPicker(anchor, messageId) {
document.querySelector('.emoji-picker')?.remove();
const picker = document.createElement('div');
picker.className = 'emoji-picker';
EMOJIS.forEach(emoji => {
const span = document.createElement('span');
span.textContent = emoji;
span.onclick = () => {
toggleReaction(messageId, emoji);
picker.remove();
};
picker.appendChild(span);
});
document.body.appendChild(picker);
const rect = anchor.getBoundingClientRect();
picker.style.top = `${rect.top - picker.offsetHeight - 5}px`;
picker.style.left = `${rect.left}px`;
}
function updateReactionUI(messageId, reactions) {
const container = document.querySelector(`.message-reactions[data-message-id="${messageId}"]`);
if (!container) return;
const addBtn = container.querySelector('.add-reaction-btn');
container.innerHTML = '';
reactions.forEach(r => {
const badge = document.createElement('span');
const userList = r.users.split(',');
const active = userList.includes(String(window.currentUserId));
badge.className = `reaction-badge ${active ? 'active' : ''}`;
badge.dataset.emoji = r.emoji;
badge.innerHTML = `${r.emoji} <span class="count">${r.count}</span>`;
container.appendChild(badge);
});
container.appendChild(addBtn);
}
function updatePresenceUI(userId, status) {
const memberItem = document.querySelector(`.start-dm-btn[data-user-id="${userId}"] .message-avatar`);
if (memberItem) {
let indicator = memberItem.querySelector('.presence-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'presence-indicator';
memberItem.appendChild(indicator);
}
indicator.style.position = 'absolute';
indicator.style.bottom = '0';
indicator.style.right = '0';
indicator.style.width = '10px';
indicator.style.height = '10px';
indicator.style.borderRadius = '50%';
indicator.style.border = '2px solid var(--bg-members)';
indicator.style.backgroundColor = status === 'online' ? '#23a559' : '#80848e';
}
}
// Voice
if (voiceHandler) {
document.querySelectorAll('.voice-item').forEach(item => {
item.addEventListener('click', () => {
const cid = item.dataset.channelId;
if (voiceHandler.currentChannelId == cid) {
voiceHandler.leave();
item.classList.remove('active');
} else {
voiceHandler.join(cid);
document.querySelectorAll('.voice-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
}
});
});
}
// Message Actions (Edit/Delete)
document.addEventListener('click', async (e) => {
const editBtn = e.target.closest('.action-btn.edit');
if (editBtn) {
const msgId = editBtn.dataset.id;
const msgItem = editBtn.closest('.message-item');
const textEl = msgItem.querySelector('.message-text');
const originalContent = textEl.innerText;
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control bg-dark text-white';
input.value = originalContent;
textEl.innerHTML = '';
textEl.appendChild(input);
input.focus();
input.onkeydown = async (ev) => {
if (ev.key === 'Enter') {
const newContent = input.value.trim();
if (newContent && newContent !== originalContent) {
const resp = await fetch('api_v1_messages.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: msgId, content: newContent })
});
if ((await resp.json()).success) {
textEl.innerHTML = newContent.replace(/\n/g, '<br>');
ws?.send(JSON.stringify({ type: 'message_edit', message_id: msgId, content: newContent }));
}
} else {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
}
} else if (ev.key === 'Escape') {
textEl.innerHTML = originalContent.replace(/\n/g, '<br>');
}
};
return;
}
const deleteBtn = e.target.closest('.action-btn.delete');
if (deleteBtn) {
if (!confirm('Delete this message?')) return;
const msgId = deleteBtn.dataset.id;
const resp = await fetch('api_v1_messages.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: msgId })
});
if ((await resp.json()).success) {
deleteBtn.closest('.message-item').remove();
ws?.send(JSON.stringify({ type: 'message_delete', message_id: msgId }));
}
return;
}
// Start DM
const dmBtn = e.target.closest('.start-dm-btn');
if (dmBtn) {
const userId = dmBtn.dataset.userId;
const formData = new FormData();
formData.append('user_id', userId);
const resp = await fetch('api_v1_dms.php', { method: 'POST', body: formData });
const result = await resp.json();
if (result.success) {
window.location.href = `?server_id=dms&channel_id=${result.channel_id}`;
}
}
});
// Global Search
const searchInput = document.getElementById('global-search');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('input', async () => {
const q = searchInput.value.trim();
if (q.length < 2) {
searchResults.style.display = 'none';
return;
}
const resp = await fetch(`api_v1_search.php?q=${encodeURIComponent(q)}&channel_id=${currentChannel}`);
const data = await resp.json();
if (data.success && data.results.length > 0) {
searchResults.innerHTML = '';
data.results.forEach(res => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.innerHTML = `
<div class="search-result-author">${res.username}</div>
<div class="search-result-text">${res.content}</div>
`;
item.onclick = () => {
// Logic to scroll to message would go here
searchResults.style.display = 'none';
};
searchResults.appendChild(item);
});
searchResults.style.display = 'block';
} else {
searchResults.innerHTML = '<div class="p-2 text-muted">No results found</div>';
searchResults.style.display = 'block';
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
searchResults.style.display = 'none';
}
});
// Roles Management
const rolesTabBtn = document.getElementById('roles-tab-btn');
const rolesList = document.getElementById('roles-list');
const addRoleBtn = document.getElementById('add-role-btn');
const activeServerId = new URLSearchParams(window.location.search).get('server_id') || 1;
rolesTabBtn?.addEventListener('click', loadRoles);
async function loadRoles() {
rolesList.innerHTML = '<div class="text-center p-3 text-muted">Loading roles...</div>';
try {
const resp = await fetch(`api_v1_roles.php?server_id=${activeServerId}`);
const data = await resp.json();
if (data.success) {
renderRoles(data.roles);
}
} catch (e) { console.error(e); }
}
function renderRoles(roles) {
rolesList.innerHTML = '';
roles.forEach(role => {
const item = document.createElement('div');
item.className = 'list-group-item bg-transparent text-white border-secondary d-flex justify-content-between align-items-center p-2';
item.innerHTML = `
<div class="d-flex align-items-center">
<div style="width: 12px; height: 12px; border-radius: 50%; background-color: ${role.color}; margin-right: 10px;"></div>
<span>${role.name}</span>
</div>
<div>
<button class="btn btn-sm btn-outline-light edit-role-btn" data-id="${role.id}">Edit</button>
<button class="btn btn-sm btn-outline-danger delete-role-btn" data-id="${role.id}">×</button>
</div>
`;
rolesList.appendChild(item);
});
}
addRoleBtn?.addEventListener('click', async () => {
const name = prompt('Role name:');
if (!name) return;
const color = prompt('Role color (hex):', '#99aab5');
try {
const resp = await fetch('api_v1_roles.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', server_id: activeServerId, name, color })
});
if ((await resp.json()).success) loadRoles();
} catch (e) { console.error(e); }
});
rolesList?.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-role-btn')) {
if (!confirm('Delete this role?')) return;
const roleId = e.target.dataset.id;
const resp = await fetch('api_v1_roles.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: roleId })
});
if ((await resp.json()).success) loadRoles();
}
if (e.target.classList.contains('edit-role-btn')) {
const roleId = e.target.dataset.id;
const name = prompt('New name:');
const color = prompt('New color (hex):');
if (!name || !color) return;
const resp = await fetch('api_v1_roles.php', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: roleId, name, color, permissions: 0 })
});
if ((await resp.json()).success) loadRoles();
}
});
// Server Settings
const searchServerIconBtn = document.getElementById('search-server-icon-btn');
const serverIconResults = document.getElementById('server-icon-search-results');
const serverIconPreview = document.getElementById('server-icon-preview');
const serverIconUrlInput = document.getElementById('server-icon-url');
searchServerIconBtn?.addEventListener('click', async () => {
const query = prompt('Search for a server icon:', 'abstract');
if (!query) return;
serverIconResults.innerHTML = '<div class="text-muted small">Searching...</div>';
try {
const resp = await fetch(`api/pexels.php?action=search&query=${encodeURIComponent(query)}`);
const data = await resp.json();
serverIconResults.innerHTML = '';
data.forEach(photo => {
const img = document.createElement('img');
img.src = photo.url;
img.className = 'avatar-pick';
img.style.width = '50px';
img.style.height = '50px';
img.onclick = () => {
serverIconUrlInput.value = photo.url;
serverIconPreview.style.backgroundImage = `url('${photo.url}')`;
serverIconResults.innerHTML = '';
};
serverIconResults.appendChild(img);
});
} catch (e) {
serverIconResults.innerHTML = '<div class="text-danger small">Error fetching icons</div>';
}
});
});
@ -87,61 +480,61 @@ function appendMessage(msg) {
const messagesList = document.getElementById('messages-list');
const div = document.createElement('div');
div.className = 'message-item';
div.dataset.id = msg.id;
const avatarStyle = msg.avatar_url ? `background-image: url('${msg.avatar_url}');` : '';
let attachmentHtml = '';
if (msg.attachment_url) {
const ext = msg.attachment_url.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
attachmentHtml = `<div class="message-attachment mt-2"><img src="${msg.attachment_url}" class="img-fluid rounded message-img-preview" alt="Attachment" style="max-height: 300px; cursor: pointer;" onclick="window.open(this.src)"></div>`;
} else {
attachmentHtml = `<div class="message-attachment mt-2"><a href="${msg.attachment_url}" target="_blank" class="attachment-link d-inline-flex align-items-center p-2 rounded bg-dark text-white text-decoration-none"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>${msg.attachment_url.split('/').pop()}</a></div>`;
}
}
let embedHtml = '';
if (msg.metadata) {
const meta = typeof msg.metadata === 'string' ? JSON.parse(msg.metadata) : msg.metadata;
embedHtml = `
<div class="rich-embed mt-2 p-3 rounded" style="background: rgba(0,0,0,0.1); border-left: 4px solid var(--blurple); max-width: 520px;">
${meta.site_name ? `<div class="embed-site-name mb-1" style="font-size: 0.75em; color: var(--text-muted); text-transform: uppercase; font-weight: bold;">${meta.site_name}</div>` : ''}
${meta.title ? `<a href="${meta.url}" target="_blank" class="embed-title d-block mb-1 text-decoration-none" style="font-weight: 600; color: #00a8fc;">${meta.title}</a>` : ''}
${meta.description ? `<div class="embed-description mb-2" style="font-size: 0.9em; color: var(--text-normal);">${meta.description}</div>` : ''}
${meta.image ? `<div class="embed-image"><img src="${meta.image}" class="rounded" style="max-width: 100%; max-height: 300px; object-fit: contain;"></div>` : ''}
</div>
`;
}
const isMe = msg.user_id == window.currentUserId || msg.username == window.currentUsername;
const actionsHtml = isMe ? `
<div class="message-actions-menu">
<span class="action-btn edit" title="Edit" data-id="${msg.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</span>
<span class="action-btn delete" title="Delete" data-id="${msg.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</span>
</div>
` : '';
div.innerHTML = `
<div class="message-avatar"></div>
<div class="message-avatar" style="${avatarStyle}"></div>
<div class="message-content">
<div class="message-author">
${msg.username}
<span class="message-time">${msg.time}</span>
${actionsHtml}
</div>
<div class="message-text">
${msg.content.replace(/\n/g, '<br>')}
${attachmentHtml}
${embedHtml}
</div>
<div class="message-reactions mt-1" data-message-id="${msg.id}">
<span class="add-reaction-btn" title="Add Reaction">+</span>
</div>
</div>
`;
messagesList.appendChild(div);
}
});
});
const result = await response.json();
if (result.success) {
// If WS is connected, we might want to let WS handle the UI update
// But for simplicity, we append here and also send to WS
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
...result.message,
channel_id: channel_id
}));
} else {
appendMessage(result.message);
messagesList.scrollTop = messagesList.scrollHeight;
}
} else {
alert('Error: ' + result.error);
}
} catch (err) {
console.error('Failed to send message:', err);
}
});
function appendMessage(msg) {
const div = document.createElement('div');
div.className = 'message-item';
div.innerHTML = `
<div class="message-avatar"></div>
<div class="message-content">
<div class="message-author">
${msg.username}
<span class="message-time">${msg.time}</span>
</div>
<div class="message-text">
${msg.content.replace(/\n/g, '<br>')}
</div>
</div>
`;
messagesList.appendChild(div);
}
});

View File

@ -1,30 +1,198 @@
// Placeholder for WebRTC Voice Logic
class VoiceChannel {
constructor(ws) {
this.ws = ws;
this.localStream = null;
this.peers = {};
this.peers = {}; // userId -> RTCPeerConnection
this.participants = {}; // userId -> username
this.currentChannelId = null;
}
async join(channelId) {
if (this.currentChannelId === channelId) return;
if (this.currentChannelId) this.leave();
console.log('Joining voice channel:', channelId);
this.currentChannelId = channelId;
try {
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
this.ws.send(JSON.stringify({
type: 'voice_join',
channel_id: channelId
channel_id: channelId,
user_id: window.currentUserId,
username: window.currentUsername
}));
// Signalization would happen here via WS
this.updateVoiceUI();
} catch (e) {
console.error('Failed to get local stream:', e);
alert('Could not access microphone.');
this.currentChannelId = null;
}
}
leave() {
if (!this.currentChannelId) return;
this.ws.send(JSON.stringify({
type: 'voice_leave',
channel_id: this.currentChannelId,
user_id: window.currentUserId
}));
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
this.ws.send(JSON.stringify({ type: 'voice_leave' }));
Object.values(this.peers).forEach(pc => pc.close());
this.peers = {};
this.participants = {};
this.currentChannelId = null;
this.updateVoiceUI();
}
async handleSignaling(data) {
const { type, from, to, offer, answer, candidate, channel_id, username } = data;
if (channel_id != this.currentChannelId) return;
if (to && to != window.currentUserId) return;
switch (type) {
case 'voice_join':
if (from != window.currentUserId) {
this.participants[from] = username || `User ${from}`;
this.createPeerConnection(from, true);
this.updateVoiceUI();
}
break;
case 'voice_offer':
this.participants[from] = username || `User ${from}`;
await this.handleOffer(from, offer);
this.updateVoiceUI();
break;
case 'voice_answer':
await this.handleAnswer(from, answer);
break;
case 'voice_ice_candidate':
await this.handleCandidate(from, candidate);
break;
case 'voice_leave':
if (this.peers[from]) {
this.peers[from].close();
delete this.peers[from];
}
delete this.participants[from];
this.updateVoiceUI();
break;
}
}
createPeerConnection(userId, isOfferor) {
if (this.peers[userId]) return this.peers[userId];
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.peers[userId] = pc;
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
pc.onicecandidate = (event) => {
if (event.candidate) {
this.ws.send(JSON.stringify({
type: 'voice_ice_candidate',
to: userId,
from: window.currentUserId,
candidate: event.candidate,
channel_id: this.currentChannelId
}));
}
};
pc.ontrack = (event) => {
const remoteAudio = new Audio();
remoteAudio.srcObject = event.streams[0];
remoteAudio.play();
};
if (isOfferor) {
pc.createOffer().then(offer => {
return pc.setLocalDescription(offer);
}).then(() => {
this.ws.send(JSON.stringify({
type: 'voice_offer',
to: userId,
from: window.currentUserId,
username: window.currentUsername,
offer: pc.localDescription,
channel_id: this.currentChannelId
}));
});
}
return pc;
}
async handleOffer(from, offer) {
const pc = this.createPeerConnection(from, false);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.ws.send(JSON.stringify({
type: 'voice_answer',
to: from,
from: window.currentUserId,
answer: pc.localDescription,
channel_id: this.currentChannelId
}));
}
async handleAnswer(from, answer) {
const pc = this.peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
async handleCandidate(from, candidate) {
const pc = this.peers[from];
if (pc) await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
updateVoiceUI() {
document.querySelectorAll('.voice-users-list').forEach(el => el.innerHTML = '');
if (this.currentChannelId) {
const channelEl = document.querySelector(`.voice-item[data-channel-id="${this.currentChannelId}"]`);
if (channelEl) {
let listEl = channelEl.querySelector('.voice-users-list');
if (!listEl) {
listEl = document.createElement('div');
listEl.className = 'voice-users-list ms-3';
channelEl.appendChild(listEl);
}
// Me
this.addVoiceUserToUI(listEl, window.currentUserId, window.currentUsername);
// Others
Object.entries(this.participants).forEach(([uid, name]) => {
this.addVoiceUserToUI(listEl, uid, name);
});
}
}
}
addVoiceUserToUI(container, userId, username) {
const userEl = document.createElement('div');
userEl.className = 'voice-user small text-muted d-flex align-items-center mb-1';
userEl.innerHTML = `
<div class="message-avatar me-2" style="width: 16px; height: 16px;"></div>
<span>${username}</span>
`;
container.appendChild(userEl);
}
}

View File

@ -0,0 +1,13 @@
-- Migration to add attachments and reactions
ALTER TABLE messages ADD COLUMN attachment_url VARCHAR(255) AFTER content;
CREATE TABLE IF NOT EXISTS message_reactions (
id INT AUTO_INCREMENT PRIMARY KEY,
message_id INT NOT NULL,
user_id INT NOT NULL,
emoji VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (message_id, user_id, emoji),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@ -0,0 +1,26 @@
-- Migration to support DMs and Message Editing
ALTER TABLE messages ADD COLUMN updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP;
-- Support for DMs in channels table
ALTER TABLE channels MODIFY COLUMN server_id INT NULL;
ALTER TABLE channels MODIFY COLUMN type ENUM('text', 'voice', 'dm') DEFAULT 'text';
-- Track members in channels (especially for DMs)
CREATE TABLE IF NOT EXISTS channel_members (
channel_id INT NOT NULL,
user_id INT NOT NULL,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Notifications: Track last read message per channel per user
CREATE TABLE IF NOT EXISTS channel_last_read (
channel_id INT NOT NULL,
user_id INT NOT NULL,
last_read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (channel_id, user_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

54
includes/opengraph.php Normal file
View File

@ -0,0 +1,54 @@
<?php
function fetchOpenGraphData($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; Bot/1.0)');
$html = curl_exec($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if (!$html || $info['http_code'] !== 200) return null;
$doc = new DOMDocument();
@$doc->loadHTML($html);
$metas = $doc->getElementsByTagName('meta');
$data = [
'title' => '',
'description' => '',
'image' => '',
'url' => $url,
'site_name' => ''
];
// Try title tag if og:title is missing
$titles = $doc->getElementsByTagName('title');
if ($titles->length > 0) {
$data['title'] = $titles->item(0)->nodeValue;
}
foreach ($metas as $meta) {
$property = $meta->getAttribute('property');
$name = $meta->getAttribute('name');
$content = $meta->getAttribute('content');
if ($property === 'og:title' || $name === 'twitter:title') $data['title'] = $content;
if ($property === 'og:description' || $name === 'description' || $name === 'twitter:description') $data['description'] = $content;
if ($property === 'og:image' || $name === 'twitter:image') $data['image'] = $content;
if ($property === 'og:site_name') $data['site_name'] = $content;
}
// Filter out empty results
if (empty($data['title']) && empty($data['description'])) return null;
return $data;
}
function extractUrls($text) {
$pattern = '/https?:\/\/[^\s<]+/';
preg_match_all($pattern, $text, $matches);
return $matches[0];
}

28
includes/pexels.php Normal file
View File

@ -0,0 +1,28 @@
<?php
// includes/pexels.php
function pexels_key() {
$k = getenv('PEXELS_KEY');
return $k && strlen($k) > 0 ? $k : 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18';
}
function pexels_get($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Authorization: '. pexels_key() ],
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && $resp) return json_decode($resp, true);
return null;
}
function download_to($srcUrl, $destPath) {
$data = file_get_contents($srcUrl);
if ($data === false) return false;
if (!is_dir(dirname($destPath))) mkdir(dirname($destPath), 0775, true);
return file_put_contents($destPath, $data) !== false;
}

397
index.php
View File

@ -14,41 +14,84 @@ $stmt = db()->prepare("
");
$stmt->execute([$current_user_id]);
$servers = $stmt->fetchAll();
$active_server_id = $_GET['server_id'] ?? ($servers[0]['id'] ?? 1);
$is_dm_view = (isset($_GET['server_id']) && $_GET['server_id'] == 'dms') || !isset($_GET['server_id']) && empty($servers);
// If no servers found, we might want to show a default or an empty state
// For now, let's assume the seed data or the first server works
if ($is_dm_view) {
$active_server_id = 'dms';
// Fetch DM channels
$stmt = db()->prepare("
SELECT c.id, u.username as other_user, u.avatar_url, u.status, u.id as other_user_id
FROM channels c
JOIN channel_members cm1 ON c.id = cm1.channel_id
JOIN channel_members cm2 ON c.id = cm2.channel_id
JOIN users u ON cm2.user_id = u.id
WHERE c.type = 'dm' AND cm1.user_id = ? AND cm2.user_id != ?
");
$stmt->execute([$current_user_id, $current_user_id]);
$dm_channels = $stmt->fetchAll();
$active_channel_id = $_GET['channel_id'] ?? ($dm_channels[0]['id'] ?? 0);
if ($active_channel_id) {
// Fetch DM messages
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ?
ORDER BY m.created_at ASC
LIMIT 50
");
$stmt->execute([$active_channel_id]);
$messages = $stmt->fetchAll();
$current_channel_name = 'Direct Message';
foreach($dm_channels as $dm) {
if ($dm['id'] == $active_channel_id) {
$current_channel_name = $dm['other_user'];
break;
}
}
} else {
$messages = [];
$current_channel_name = 'Direct Messages';
}
$channels = [];
$members = []; // Members list is different for DMs or hidden
} else {
$active_server_id = $_GET['server_id'] ?? ($servers[0]['id'] ?? 1);
// Fetch channels
$stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?");
$stmt->execute([$active_server_id]);
$channels = $stmt->fetchAll();
$active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1);
// Fetch channels
$stmt = db()->prepare("SELECT * FROM channels WHERE server_id = ?");
$stmt->execute([$active_server_id]);
$channels = $stmt->fetchAll();
$active_channel_id = $_GET['channel_id'] ?? ($channels[0]['id'] ?? 1);
// Fetch messages
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ?
ORDER BY m.created_at ASC
LIMIT 50
");
$stmt->execute([$active_channel_id]);
$messages = $stmt->fetchAll();
// Fetch messages
$stmt = db()->prepare("
SELECT m.*, u.username, u.avatar_url
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.channel_id = ?
ORDER BY m.created_at ASC
LIMIT 50
");
$stmt->execute([$active_channel_id]);
$messages = $stmt->fetchAll();
$current_channel_name = 'general';
foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name'];
$current_channel_name = 'general';
foreach($channels as $c) if($c['id'] == $active_channel_id) $current_channel_name = $c['name'];
// Fetch members
$stmt = db()->prepare("
SELECT u.username, u.avatar_url, u.status
FROM users u
JOIN server_members sm ON u.id = sm.user_id
WHERE sm.server_id = ?
");
$stmt->execute([$active_server_id]);
$members = $stmt->fetchAll();
// Fetch members
$stmt = db()->prepare("
SELECT u.id, u.username, u.avatar_url, u.status
FROM users u
JOIN server_members sm ON u.id = sm.user_id
WHERE sm.server_id = ?
");
$stmt->execute([$active_server_id]);
$members = $stmt->fetchAll();
}
// SEO & Env tags
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Discord-like messaging app built with PHP';
@ -78,15 +121,16 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="discord-app">
<!-- Servers Sidebar -->
<div class="servers-sidebar">
<a href="index.php" class="server-icon active" title="Home">
<a href="index.php?server_id=dms" class="server-icon <?php echo $active_server_id == 'dms' ? 'active' : ''; ?>" title="Direct Messages">
<svg width="28" height="20" viewBox="0 0 28 20" fill="currentColor"><path d="M23.0212 1.67671C21.3107 0.883335 19.4805 0.314845 17.5566 0C17.3137 0.434033 17.0423 1.00252 16.8488 1.46581C14.8193 1.16016 12.8016 1.16016 10.8011 1.46581C10.6076 1.00252 10.3255 0.434033 10.0827 0C8.14811 0.314845 6.31792 0.883335 4.60741 1.67671C1.14775 6.84711 0.210418 11.8962 0.67293 16.8681C2.9723 18.5677 5.19143 19.5997 7.37191 20C7.91578 19.2558 8.3897 18.4616 8.79155 17.6166C7.99616 17.3148 7.2343 16.9416 6.51603 16.505C6.70881 16.3639 6.89745 16.2125 7.07923 16.052C11.4116 18.0494 16.1264 18.0494 20.4137 16.052C20.597 16.2125 20.7856 16.3639 20.9784 16.505C20.2586 16.9416 19.4967 17.3148 18.7013 17.6166C19.1031 18.4616 19.577 19.2558 20.1209 20C22.3014 19.5997 24.5205 18.5677 26.8199 16.8681C27.3693 11.127 25.9189 6.13063 23.0212 1.67671ZM9.51636 13.6749C8.21405 13.6749 7.14188 12.4839 7.14188 11.026C7.14188 9.56816 8.19284 8.3771 9.51636 8.3771C10.8399 8.3771 11.912 9.56816 11.8908 11.026C11.8908 12.4839 10.8399 13.6749 9.51636 13.6749ZM18.0051 13.6749C16.7028 13.6749 15.6306 12.4839 15.6306 11.026C15.6306 9.56816 16.6815 8.3771 18.0051 8.3771C19.3286 8.3771 20.4008 9.56816 20.3796 11.026C20.3796 12.4839 19.3286 13.6749 18.0051 13.6749Z"/></svg>
</a>
<hr style="width: 32px; border-color: #35363c; margin: 4px 0;">
<?php foreach($servers as $s): ?>
<a href="?server_id=<?php echo $s['id']; ?>"
class="server-icon <?php echo $s['id'] == $active_server_id ? 'active' : ''; ?>"
title="<?php echo htmlspecialchars($s['name']); ?>">
<?php echo mb_substr($s['name'], 0, 1); ?>
title="<?php echo htmlspecialchars($s['name']); ?>"
style="<?php echo !empty($s['icon_url']) ? "background-image: url('{$s['icon_url']}'); background-size: cover;" : ""; ?>">
<?php echo empty($s['icon_url']) ? mb_substr($s['name'], 0, 1) : ''; ?>
</a>
<?php endforeach; ?>
<a href="#" class="server-icon add-btn" title="Add a Server" data-bs-toggle="modal" data-bs-target="#addServerModal">
@ -98,36 +142,64 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="channels-sidebar">
<div class="channels-header">
<?php
$active_server_name = 'Server';
foreach($servers as $s) if($s['id'] == $active_server_id) $active_server_name = $s['name'];
echo htmlspecialchars($active_server_name);
if ($is_dm_view) {
echo "Direct Messages";
} else {
$active_server_name = 'Server';
$is_owner = false;
foreach($servers as $s) {
if($s['id'] == $active_server_id) {
$active_server_name = $s['name'];
$is_owner = ($s['owner_id'] == $current_user_id);
break;
}
}
echo htmlspecialchars($active_server_name);
if ($is_owner): ?>
<span class="ms-auto" style="cursor: pointer;" data-bs-toggle="modal" data-bs-target="#serverSettingsModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/></svg>
</span>
<?php endif;
}
?>
</div>
<div class="channels-list">
<div class="channel-category">
<span>Text Channels</span>
<span class="add-channel-btn" title="Create Channel" data-bs-toggle="modal" data-bs-target="#addChannelModal" data-type="text">+</span>
</div>
<?php foreach($channels as $c): if($c['type'] !== 'text') continue; ?>
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item <?php echo $c['id'] == $active_channel_id ? 'active' : ''; ?>">
<?php echo htmlspecialchars($c['name']); ?>
</a>
<?php endforeach; ?>
<div class="channel-category" style="margin-top: 16px;">
<span>Voice Channels</span>
<span class="add-channel-btn" title="Create Channel" data-bs-toggle="modal" data-bs-target="#addChannelModal" data-type="voice">+</span>
</div>
<?php foreach($channels as $c): if($c['type'] !== 'voice') continue; ?>
<div class="channel-item voice-item" data-channel-id="<?php echo $c['id']; ?>">
<?php echo htmlspecialchars($c['name']); ?>
<?php if ($is_dm_view): ?>
<?php foreach($dm_channels as $dm): ?>
<a href="?server_id=dms&channel_id=<?php echo $dm['id']; ?>"
class="dm-user-item <?php echo $dm['id'] == $active_channel_id ? 'active' : ''; ?>">
<div class="message-avatar" style="width: 32px; height: 32px; <?php echo $dm['avatar_url'] ? "background-image: url('{$dm['avatar_url']}');" : ""; ?>">
<div class="dm-status-indicator dm-status-<?php echo $dm['status']; ?>" style="position: absolute; bottom: 0; right: 0;"></div>
</div>
<span><?php echo htmlspecialchars($dm['other_user']); ?></span>
</a>
<?php endforeach; ?>
<?php else: ?>
<div class="channel-category">
<span>Text Channels</span>
<span class="add-channel-btn" title="Create Channel" data-bs-toggle="modal" data-bs-target="#addChannelModal" data-type="text">+</span>
</div>
<?php endforeach; ?>
<?php foreach($channels as $c): if($c['type'] !== 'text') continue; ?>
<a href="?server_id=<?php echo $active_server_id; ?>&channel_id=<?php echo $c['id']; ?>"
class="channel-item <?php echo $c['id'] == $active_channel_id ? 'active' : ''; ?>">
<?php echo htmlspecialchars($c['name']); ?>
</a>
<?php endforeach; ?>
<div class="channel-category" style="margin-top: 16px;">
<span>Voice Channels</span>
<span class="add-channel-btn" title="Create Channel" data-bs-toggle="modal" data-bs-target="#addChannelModal" data-type="voice">+</span>
</div>
<?php foreach($channels as $c): if($c['type'] !== 'voice') continue; ?>
<div class="channel-item voice-item" data-channel-id="<?php echo $c['id']; ?>">
<?php echo htmlspecialchars($c['name']); ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="user-panel">
<div class="user-info">
<div class="message-avatar" style="width: 32px; height: 32px;"></div>
<div class="user-info" data-bs-toggle="modal" data-bs-target="#userSettingsModal">
<div class="message-avatar" style="width: 32px; height: 32px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: bold; font-size: 0.85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<?php echo htmlspecialchars($user['username']); ?>
@ -136,6 +208,9 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
</div>
</div>
<div class="user-actions">
<a href="#" title="Settings" style="color: var(--text-muted); margin-right: 8px;" data-bs-toggle="modal" data-bs-target="#userSettingsModal">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</a>
<a href="auth/logout.php" title="Logout" style="color: var(--text-muted);"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg></a>
</div>
</div>
@ -147,8 +222,13 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<!-- Chat Area -->
<div class="chat-container">
<div class="chat-header">
<span style="color: var(--text-muted); margin-right: 8px;">#</span>
<span style="color: var(--text-muted); margin-right: 8px;"><?php echo $is_dm_view ? '@' : '#'; ?></span>
<?php echo htmlspecialchars($current_channel_name); ?>
<div class="search-container">
<input type="text" id="global-search" class="search-input" placeholder="Search messages..." autocomplete="off">
<div id="search-results" class="search-results-dropdown"></div>
</div>
</div>
<div class="messages-list" id="messages-list">
<?php if(empty($messages)): ?>
@ -158,23 +238,90 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
</div>
<?php endif; ?>
<?php foreach($messages as $m): ?>
<div class="message-item">
<div class="message-avatar"></div>
<div class="message-item" data-id="<?php echo $m['id']; ?>">
<div class="message-avatar" style="<?php echo $m['avatar_url'] ? "background-image: url('{$m['avatar_url']}');" : ""; ?>"></div>
<div class="message-content">
<div class="message-author">
<?php echo htmlspecialchars($m['username']); ?>
<span class="message-time"><?php echo date('H:i', strtotime($m['created_at'])); ?></span>
<?php if ($m['user_id'] == $current_user_id): ?>
<div class="message-actions-menu">
<span class="action-btn edit" title="Edit" data-id="<?php echo $m['id']; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</span>
<span class="action-btn delete" title="Delete" data-id="<?php echo $m['id']; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</span>
</div>
<?php endif; ?>
</div>
<div class="message-text">
<?php echo nl2br(htmlspecialchars($m['content'])); ?>
<?php if ($m['attachment_url']): ?>
<div class="message-attachment mt-2">
<?php
$ext = strtolower(pathinfo($m['attachment_url'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])):
?>
<img src="<?php echo htmlspecialchars($m['attachment_url']); ?>" class="img-fluid rounded message-img-preview" alt="Attachment" style="max-height: 300px; cursor: pointer;" onclick="window.open(this.src)">
<?php else: ?>
<a href="<?php echo htmlspecialchars($m['attachment_url']); ?>" target="_blank" class="attachment-link d-inline-flex align-items-center p-2 rounded bg-dark text-white text-decoration-none">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
<?php echo basename($m['attachment_url']); ?>
</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($m['metadata'])):
$meta = json_decode($m['metadata'], true);
if ($meta): ?>
<div class="rich-embed mt-2 p-3 rounded" style="background: rgba(0,0,0,0.1); border-left: 4px solid var(--blurple); max-width: 520px;">
<?php if (!empty($meta['site_name'])): ?>
<div class="embed-site-name mb-1" style="font-size: 0.75em; color: var(--text-muted); text-transform: uppercase; font-weight: bold;"><?php echo htmlspecialchars($meta['site_name']); ?></div>
<?php endif; ?>
<?php if (!empty($meta['title'])): ?>
<a href="<?php echo htmlspecialchars($meta['url']); ?>" target="_blank" class="embed-title d-block mb-1 text-decoration-none" style="font-weight: 600; color: #00a8fc;"><?php echo htmlspecialchars($meta['title']); ?></a>
<?php endif; ?>
<?php if (!empty($meta['description'])): ?>
<div class="embed-description mb-2" style="font-size: 0.9em; color: var(--text-normal);"><?php echo htmlspecialchars($meta['description']); ?></div>
<?php endif; ?>
<?php if (!empty($meta['image'])): ?>
<div class="embed-image">
<img src="<?php echo htmlspecialchars($meta['image']); ?>" class="rounded" style="max-width: 100%; max-height: 300px; object-fit: contain;">
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="message-reactions mt-1" data-message-id="<?php echo $m['id']; ?>">
<?php
// Fetch reactions for this message
$stmt_react = db()->prepare("SELECT emoji, COUNT(*) as count, GROUP_CONCAT(user_id) as users FROM message_reactions WHERE message_id = ? GROUP BY emoji");
$stmt_react->execute([$m['id']]);
$reactions = $stmt_react->fetchAll();
foreach ($reactions as $r):
$reacted = in_array($current_user_id, explode(',', $r['users']));
?>
<span class="reaction-badge <?php echo $reacted ? 'active' : ''; ?>" data-emoji="<?php echo htmlspecialchars($r['emoji']); ?>">
<?php echo htmlspecialchars($r['emoji']); ?> <span class="count"><?php echo $r['count']; ?></span>
</span>
<?php endforeach; ?>
<span class="add-reaction-btn" title="Add Reaction">+</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div id="typing-indicator" class="typing-indicator"></div>
<div class="chat-input-container">
<form id="chat-form">
<form id="chat-form" enctype="multipart/form-data">
<div class="chat-input-wrapper">
<label for="file-upload" class="upload-btn-label" title="Upload File">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>
</label>
<input type="file" id="file-upload" style="display: none;">
<input type="text" id="chat-input" class="chat-input" placeholder="Message #<?php echo htmlspecialchars($current_channel_name); ?>" autocomplete="off">
</div>
</form>
@ -187,8 +334,8 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
Members <?php echo count($members); ?>
</div>
<?php foreach($members as $m): ?>
<div class="channel-item" style="color: var(--text-primary); margin-bottom: 8px;">
<div class="message-avatar" style="width: 32px; height: 32px; background-color: <?php echo $m['status'] == 'online' ? '#23a559' : '#80848e'; ?>; position: relative;">
<div class="channel-item start-dm-btn" data-user-id="<?php echo $m['id']; ?>" style="color: var(--text-primary); margin-bottom: 8px;">
<div class="message-avatar" style="width: 32px; height: 32px; background-color: <?php echo $m['status'] == 'online' ? '#23a559' : '#80848e'; ?>; position: relative; <?php echo $m['avatar_url'] ? "background-image: url('{$m['avatar_url']}');" : ""; ?>">
<?php if($m['status'] == 'online'): ?>
<div style="position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; background-color: #23a559; border-radius: 50%; border: 2px solid var(--bg-members);"></div>
<?php endif; ?>
@ -201,6 +348,126 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
</div>
</div>
<!-- User Settings Modal -->
<div class="modal fade" id="userSettingsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">User Settings</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="user-settings-form">
<div class="row">
<div class="col-md-4 text-center">
<div class="message-avatar mx-auto mb-3" id="settings-avatar-preview" style="width: 100px; height: 100px; <?php echo $user['avatar_url'] ? "background-image: url('{$user['avatar_url']}');" : ""; ?>"></div>
<input type="hidden" name="avatar_url" id="settings-avatar-url" value="<?php echo htmlspecialchars($user['avatar_url'] ?? ''); ?>">
<p class="small text-muted">Pick an avatar from Pexels or search for one.</p>
</div>
<div class="col-md-8">
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Username</label>
<input type="text" name="username" class="form-control" value="<?php echo htmlspecialchars($user['username']); ?>" required>
</div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Search Avatars</label>
<div class="input-group mb-2">
<input type="text" id="avatar-search-query" class="form-control" placeholder="e.g. cat, abstract, gamer">
<button class="btn btn-outline-secondary" type="button" id="search-avatar-btn">Search</button>
</div>
<div id="avatar-results" class="d-flex flex-wrap gap-2 overflow-auto" style="max-height: 200px;">
<!-- Pexels results here -->
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link text-white text-decoration-none" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="save-settings-btn" class="btn btn-primary" style="background-color: var(--blurple); border: none; padding: 10px 24px;">Save Changes</button>
</div>
</div>
</div>
</div>
<!-- Server Settings Modal -->
<div class="modal fade" id="serverSettingsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Server Settings</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<ul class="nav nav-tabs nav-fill" id="serverSettingsTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active text-white border-0 bg-transparent" data-bs-toggle="tab" data-bs-target="#settings-general" type="button">General</button>
</li>
<li class="nav-item">
<button class="nav-link text-white border-0 bg-transparent" id="roles-tab-btn" data-bs-toggle="tab" data-bs-target="#settings-roles" type="button">Roles</button>
</li>
</ul>
<div class="tab-content p-3">
<div class="tab-pane fade show active" id="settings-general">
<form action="api_v1_servers.php" method="POST" id="server-settings-form">
<input type="hidden" name="action" value="update">
<input type="hidden" name="server_id" value="<?php echo $active_server_id; ?>">
<div class="mb-3 text-center">
<?php
$active_icon = '';
foreach($servers as $s) if($s['id'] == $active_server_id) $active_icon = $s['icon_url'];
?>
<div class="message-avatar mx-auto mb-2" id="server-icon-preview" style="width: 80px; height: 80px; <?php echo $active_icon ? "background-image: url('{$active_icon}');" : ""; ?>"></div>
<input type="hidden" name="icon_url" id="server-icon-url" value="<?php echo htmlspecialchars($active_icon); ?>">
<button type="button" class="btn btn-sm btn-outline-secondary" id="search-server-icon-btn">Change Icon</button>
</div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Server Name</label>
<input type="text" name="name" class="form-control" value="<?php echo htmlspecialchars($active_server_name ?? ''); ?>" required>
</div>
<div id="server-icon-search-results" class="d-flex flex-wrap gap-2 mb-3 overflow-auto" style="max-height: 150px;"></div>
<div class="mb-3">
<label class="form-label text-uppercase fw-bold" style="font-size: 0.7em; color: var(--text-muted);">Invite Code</label>
<?php
$invite = '';
foreach($servers as $s) if($s['id'] == $active_server_id) $invite = $s['invite_code'];
?>
<div class="input-group">
<input type="text" class="form-control bg-dark text-white border-0" value="<?php echo $invite; ?>" readonly>
<button class="btn btn-secondary" type="button" onclick="navigator.clipboard.writeText('<?php echo $invite; ?>')">Copy</button>
</div>
</div>
<hr class="border-secondary">
<button type="submit" class="btn btn-primary w-100 mb-2">Save Changes</button>
</form>
<form action="api_v1_servers.php" method="POST" onsubmit="return confirm('Are you sure you want to delete this server? This action is irreversible.');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="server_id" value="<?php echo $active_server_id; ?>">
<button type="submit" class="btn btn-danger w-100">Delete Server</button>
</form>
</div>
<div class="tab-pane fade" id="settings-roles">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Server Roles</h6>
<button class="btn btn-sm btn-primary" id="add-role-btn">+ Add Role</button>
</div>
<div id="roles-list" class="list-group list-group-flush bg-transparent">
<!-- Roles will be loaded here -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Server Modal -->
<div class="modal fade" id="addServerModal" tabindex="-1">
<div class="modal-dialog">
@ -276,6 +543,10 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
window.currentUserId = <?php echo $current_user_id; ?>;
window.currentUsername = "<?php echo addslashes($user['username']); ?>";
</script>
<script src="assets/js/voice.js?v=<?php echo time(); ?>"></script>
<script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
<script>

View File

@ -39,9 +39,17 @@ while (true) {
$decoded_data = unmask($data);
if ($decoded_data) {
$response = mask(json_encode(['type' => 'message', 'data' => $decoded_data]));
$payload = json_decode($decoded_data, true);
if ($payload) {
// If it's already a structured message, just broadcast it
$response = mask($decoded_data);
} else {
// Fallback for raw text
$response = mask(json_encode(['type' => 'message', 'data' => $decoded_data]));
}
foreach ($clients as $client) {
if ($client != $socket) {
if ($client != $socket && $client != $client_socket) {
@socket_write($client, $response, strlen($response));
}
}