/** * tmesschat.js — web chat widget for Taleoi. * Calls /tmesschat (same API as the Android app). * Integrates with the socket signaling server for real-time delivery. */ (function ($) { 'use strict'; // ── Config ────────────────────────────────────────────────────────────────── var API_URL = '/tmesschat'; // No explicit :3001 — the Node signaling server only speaks plain (non-TLS) HTTP on that raw // port; the default HTTPS port goes through the nginx reverse proxy that actually terminates // TLS and forwards to it, same as the Android app's AppConstants.SOCKET_URL. Hitting :3001 // directly with https/wss fails the TLS handshake every time (confirmed via curl — plain TCP // connects fine, but "SSL routines::wrong version number" since the server there isn't TLS). var SOCKET_URL = 'https://www.taleoi.com'; var FILES_BASE = 'https://www.taleoi.com/sites/default/files/'; var POLL_MS = 5000; var LOC_LABEL = 'Shared location'; // Client-side pre-upload compression — keeps chat images snappy on slow connections // and under the server's 8MB ceiling (see tmesschat_handler() opc 11I). var COMPRESS_THRESHOLD = 1024 * 1024; // only compress files bigger than this (1MB) var COMPRESS_MAX_DIM = 1600; // longest side, in px, after resize var COMPRESS_QUALITY = 0.8; // Inline reply/forward markers — identical to the Android app constants. // Written as Unicode escape sequences to avoid raw control character bytes. var REPLY_MARKER = '\u0002R\u001F'; var FWD_MARKER = '\u0002F\u001F'; var LOC_MARKER = '\u0002L\u001F'; var META_SEP = '\u001F'; var META_ETX = '\u0003'; // ── State ─────────────────────────────────────────────────────────────────── var userId = '0'; var myName = ''; var activeCid = null; var activeUid = null; var activeName = null; var activeGid = null; // set instead of activeCid when a GROUP conversation is open var allRooms = {}; // cid → room object from API var allMsgs = {}; // cid → messages array (newest-first, as returned by API) var unreadCids = {}; // cid → true when a new message arrived while chat was not open var socket = null; var pollTimer = null; var isOpen = false; var isTyping = false; var typingTimer = null; var loaded = false; // Groups (tc_group / tc_group_member / tc_group_message) — fully parallel to the 1:1 state // above, exactly like the Android app: never touches tc_chatroom/tc_chatmsm, comes entirely // from the signaling server's group-* socket events (there is no Drupal/HTTP path for groups // at all, unlike 1:1 chat's /tmesschat polling). var allGroups = {}; // gid → group object (name, pictureUrl, updated, unreadCount, lastMessage, ...) var allGroupMsgs = {}; // gid → messages array (newest-first) var pendingGroupImage = null; // { gid, localId } for the in-flight group image send, if any // ── Bootstrap (Drupal.behaviors + legacy ready fallback) ──────────────────── function init() { if ($('#tc-bar').data('tc-init')) return; userId = $('#tc-userid').val() || '0'; myName = $('#tc-username').val() || ''; if (!userId || userId === '0') return; $('#tc-bar').data('tc-init', true); bindUI(); initSocket(); firstLoad(); window._tcOpenRoom = function (cid, uid, name) { if (!isOpen) { togglePanel(); } openChat(String(cid), String(uid), String(name)); }; } if (typeof Drupal !== 'undefined' && Drupal.behaviors) { Drupal.behaviors.tmesschat = { attach: function () { init(); } }; } else { $(document).ready(init); } // ── Socket ────────────────────────────────────────────────────────────────── function initSocket() { if (typeof io === 'undefined') return; try { socket = io(SOCKET_URL, { transports: ['websocket', 'polling'] }); window._tcSocket = socket; // debug hook — inspect from the browser console as needed socket.on('connect', function () { socket.emit('join', userId, myName); // One-shot bulk sync on every (re)connect — mirrors MyServiceZ's EVENT_CONNECT hook in // the Android app, since there's no HTTP polling fallback for groups to lean on. socket.emit('group-list', { uid: userId }); }); socket.on('receive-message', function (data) { onSocketMessage(data); }); socket.on('group-list', function (data) { processGroups(data && data.groups); if (isOpen) renderRooms(); }); socket.on('group-created', function (snapshot) { onGroupSnapshot(snapshot); }); socket.on('group-updated', function (snapshot) { onGroupSnapshot(snapshot); }); socket.on('group-member-added', function (snapshot) { onGroupSnapshot(snapshot); }); socket.on('group-member-removed', function (snapshot) { onGroupSnapshot(snapshot); }); socket.on('group-removed-you', function (data) { onGroupGone(data); }); socket.on('group-deleted', function (data) { onGroupGone(data); }); socket.on('group-message', function (data) { onGroupSocketMessage(data); }); socket.on('group-history', function (data) { onGroupHistory(data); }); socket.on('group-message-error', function (data) { onGroupMessageError(data); }); } catch (e) { socket = null; } } // Every group-create/update/member-added/member-removed broadcast carries the same full // snapshot shape ({gid, name, description, pictureUrl, creatorUid, updated, members:[...]}) // — mirrors MyServiceZ.upsertGroupFromSnapshot(). lastMessage/unreadCount aren't part of a // snapshot (those only ever come from group-list/group-message), so they're preserved from // whatever's already cached. function onGroupSnapshot(snapshot) { if (!snapshot || !snapshot.gid) return; var gid = String(snapshot.gid); var existing = allGroups[gid] || {}; var myRole = 'member'; (snapshot.members || []).forEach(function (m) { if (String(m.uid) === String(userId)) myRole = m.role; }); allGroups[gid] = { gid: gid, name: snapshot.name || '', description: snapshot.description || '', pictureUrl: snapshot.pictureUrl || '', creatorUid: snapshot.creatorUid || '', role: myRole, updated: snapshot.updated || existing.updated || 0, memberCount: (snapshot.members || []).length, unreadCount: existing.unreadCount || 0, lastMessage: existing.lastMessage || null }; if (isOpen) renderRooms(); } function onGroupGone(data) { if (!data || !data.gid) return; var gid = String(data.gid); delete allGroups[gid]; delete allGroupMsgs[gid]; if (activeGid === gid) { activeGid = null; $('#tc-chat-view').addClass('tc-hidden'); $('#tc-rooms-view').removeClass('tc-hidden'); } if (isOpen) renderRooms(); } function processGroups(groups) { if (!groups) return; groups.forEach(function (g) { var gid = String(g.gid); allGroups[gid] = { gid: gid, name: g.name || '', description: g.description || '', pictureUrl: g.pictureUrl || '', creatorUid: g.creatorUid || '', role: g.role || 'member', updated: g.updated || 0, memberCount: g.memberCount || 0, unreadCount: g.unreadCount || 0, lastMessage: g.lastMessage || null }; }); } function onGroupHistory(data) { if (!data || !data.gid) return; var gid = String(data.gid); if (activeGid !== gid) return; var msgs = data.messages || []; if (!allGroupMsgs[gid]) allGroupMsgs[gid] = []; var known = {}; allGroupMsgs[gid].forEach(function (m) { known[String(m.id)] = true; }); msgs.forEach(function (m) { if (!known[String(m.id)]) { allGroupMsgs[gid].push(m); known[String(m.id)] = true; } }); allGroupMsgs[gid].sort(function (a, b) { return parseInt(b.id, 10) - parseInt(a.id, 10); }); renderGroupMessages(gid); } function onGroupSocketMessage(data) { var gid = String(data.gid); var msg = { id: data.id, whosend: String(data.whosend), senderName: data.senderName || '', msm: data.msm || '', urlima: data.urlima || '', urlaudi: data.urlaudi || '', created: data.created || Math.floor(Date.now() / 1000) }; if (!allGroupMsgs[gid]) allGroupMsgs[gid] = []; // Drop the optimistic "Sending…" placeholder this message confirms (image sends // only — see sendGroupImage()); text messages never add one in the first place. if (data.localId) { allGroupMsgs[gid] = allGroupMsgs[gid].filter(function (m) { return m.localId !== data.localId; }); if (pendingGroupImage && pendingGroupImage.localId === data.localId) { pendingGroupImage = null; $('#tc-img-btn').removeAttr('disabled'); } } var exists = false; for (var i = 0; i < allGroupMsgs[gid].length; i++) { if (String(allGroupMsgs[gid][i].id) === String(msg.id)) { exists = true; break; } } if (!exists) allGroupMsgs[gid].unshift(msg); var isMine = String(msg.whosend) === String(userId); if (allGroups[gid]) { allGroups[gid].lastMessage = { id: msg.id, whosend: msg.whosend, msm: msg.msm, urlima: msg.urlima, urlaudi: msg.urlaudi, created: msg.created }; allGroups[gid].updated = msg.created; if (!isMine && !(activeGid === gid && isOpen)) { allGroups[gid].unreadCount = (allGroups[gid].unreadCount || 0) + 1; } } if (activeGid === gid && isOpen) { renderGroupMessages(gid); } else { updateBadge(); } if (isOpen) renderRooms(); if (document.hidden && !isMine) { var groupName = allGroups[gid] ? allGroups[gid].name : ''; showNotif(groupName || 'New group message', parseDisplayText(msg.msm)); } } function onSocketMessage(data) { var cid = String(data.chatId); var msg = { id: data.id, whosend: String(data.from), msm: data.message || '', urlima: data.urlima || '', urlaudi: data.urlaudi || '', timestamp: new Date().toISOString().replace('T', ' ').substr(0, 19) }; if (!allMsgs[cid]) allMsgs[cid] = []; var exists = false; for (var i = 0; i < allMsgs[cid].length; i++) { if (String(allMsgs[cid][i].id) === String(msg.id)) { exists = true; break; } } if (!exists) allMsgs[cid].unshift(msg); if (activeCid === cid && isOpen) { renderMessages(cid); } else { unreadCids[cid] = true; updateBadge(); } if (allRooms[cid]) { allRooms[cid]._lastMsg = msg; } if (isOpen) renderRooms(); if (document.hidden) { var roomName = allRooms[cid] ? allRooms[cid]._dispName || '' : ''; showNotif(roomName || 'New message', parseDisplayText(msg.msm)); } } // ── UI bindings ───────────────────────────────────────────────────────────── function bindUI() { $('#tc-bar').click(togglePanel); $('#tc-back').click(function () { if (activeGid && socket && socket.connected) { socket.emit('group-close', { gid: parseInt(activeGid, 10) }); } activeCid = null; activeGid = null; activeUid = null; activeName = null; $('#tc-chat-view').addClass('tc-hidden'); $('#tc-rooms-view').removeClass('tc-hidden'); renderRooms(); }); $('#tc-input').keypress(function (e) { if (e.which === 13) { e.preventDefault(); activeGid ? sendGroupMessage() : sendMessage(); } }); $('#tc-input').bind('input', function () { isTyping = true; clearTimeout(typingTimer); typingTimer = setTimeout(function () { isTyping = false; }, 3000); }); $('#tc-send').click(function () { activeGid ? sendGroupMessage() : sendMessage(); }); $('#tc-img-btn').click(function () { $('#tc-img-input').click(); }); $('#tc-img-input').bind('change', function () { var file = this.files && this.files[0]; this.value = ''; if (!file) return; if (activeGid) { sendGroupImage(file); } else { sendImage(file); } }); } function togglePanel() { isOpen = !isOpen; if (isOpen) { $('#tc-panel').removeClass('tc-hidden'); $('#tc-bar-toggle').text('▼'); if (loaded) renderRooms(); startPolling(); } else { $('#tc-panel').addClass('tc-hidden'); $('#tc-bar-toggle').text('▲'); stopPolling(); } } // ── First load (888X) ─────────────────────────────────────────────────────── function firstLoad() { post({ opc: '888X', userid: userId }).then(function (resp) { loaded = true; if (resp && resp.resultx) { var rex = resp.resultx.rex; if (rex && rex !== '' && typeof rex === 'object') { processRooms(rex); } } if (isOpen) renderRooms(); }); } // ── Polling ───────────────────────────────────────────────────────────────── function startPolling() { if (pollTimer) return; pollTimer = setInterval(function () { var opc = '8XZ'; if (activeCid) opc = isTyping ? '8XWZ' : '8XRZ'; post({ opc: opc, userid: userId, cid: activeCid || '0', last_seen: '0' }) .then(function (resp) { if (!resp || !resp.resultx) return; var rex = resp.resultx.rex; if (rex && rex !== '' && typeof rex === 'object') { processRooms(rex); if (isOpen) { renderRooms(); if (activeCid) renderMessages(activeCid); } } }); }, POLL_MS); } function stopPolling() { clearInterval(pollTimer); pollTimer = null; } // ── Process API room data ──────────────────────────────────────────────────── function processRooms(rooms) { if (!rooms) return; Object.keys(rooms).forEach(function (k) { var room = rooms[k]; var cid = String(room.id); var uids = (room.uids || '').split('-'); var names = (room.usernames || '').split('-'); var isFirst = (uids[0] === String(userId)); room._dispName = isFirst ? (names[1] || '') : (names[0] || ''); room._recipUid = isFirst ? (uids[1] || '') : (uids[0] || ''); allRooms[cid] = room; if (room.messages && room.messages.length) { if (!allMsgs[cid]) allMsgs[cid] = []; var known = {}; allMsgs[cid].forEach(function (m) { known[String(m.id)] = true; }); room.messages.forEach(function (m) { if (!known[String(m.id)]) { allMsgs[cid].push(m); known[String(m.id)] = true; } }); allMsgs[cid].sort(function (a, b) { return parseInt(b.id, 10) - parseInt(a.id, 10); }); room._lastMsg = allMsgs[cid][0] || null; } }); } // ── Render room list (1:1 rooms + groups, merged and sorted by recency together) ────────── // Mirrors FragmentChatRoom's merged list in the Android app — groups aren't a second-class // list bolted on separately, they're interleaved with 1:1 conversations by last-activity time. function renderRooms() { var $list = $('#tc-rooms-list'); var items = []; Object.keys(allRooms).forEach(function (k) { var room = allRooms[k]; var msgs = allMsgs[String(room.id)]; var ts = (msgs && msgs[0]) ? msgs[0].timestamp : '0'; items.push({ type: 'room', sortKey: ts, data: room }); }); Object.keys(allGroups).forEach(function (k) { var group = allGroups[k]; items.push({ type: 'group', sortKey: unixToDbTimestamp(group.updated || 0), data: group }); }); items.sort(function (a, b) { return b.sortKey > a.sortKey ? 1 : (b.sortKey < a.sortKey ? -1 : 0); }); if (!items.length) { $list.html('
No conversations yet
'); return; } var html = ''; items.forEach(function (item) { html += (item.type === 'group') ? renderGroupRow(item.data) : renderRoomRow(item.data); }); $list.html(html); $list.find('.tc-room[data-type="room"]').click(function () { openChat( String($(this).data('cid')), String($(this).data('uid')), String($(this).data('name')) ); }); $list.find('.tc-room[data-type="group"]').click(function () { openGroupChat(String($(this).data('gid')), String($(this).data('name'))); }); } function renderRoomRow(room) { var cid = String(room.id); var disp = room._dispName || ''; var recip = room._recipUid || ''; var online = (room.online || '0-0').split('-'); var uids = (room.uids || '').split('-'); var msgs = allMsgs[cid] || []; var lastMsg = msgs[0] || null; var theirIdx = (uids[0] === String(userId)) ? 1 : 0; var theirOnl = online[theirIdx] || '0'; var onlClass = theirOnl === '2' ? 'tc-typing' : (theirOnl === '1' ? 'tc-online' : ''); var onlLabel = theirOnl === '2' ? 'typing…' : (theirOnl === '1' ? 'online' : ''); var preview = ''; if (lastMsg) { preview = parseDisplayText(lastMsg.msm); if (!preview) { if (lastMsg.urlima && lastMsg.urlima.indexOf('public://') >= 0) preview = '📷'; else if (lastMsg.urlaudi && lastMsg.urlaudi.indexOf('public://') >= 0) preview = '🎵'; } if (String(lastMsg.whosend) === String(userId)) preview = 'You: ' + preview; } var timeStr = lastMsg ? fmtTime(lastMsg.timestamp) : ''; var unread = unreadCids[cid] || (String(room.ulast) !== String(userId) && room.status === '0'); var active = (activeCid === cid && !activeGid); return '
' + '
' + escHtml((disp || '?').charAt(0).toUpperCase()) + '
' + '
' + '
' + '' + escHtml(disp) + '' + (onlLabel ? '' + escHtml(onlLabel) + '' : '') + '' + escHtml(timeStr) + '' + '
' + '
' + '' + escHtml(preview) + '' + (unread ? '' : '') + '
' + '
' + '
'; } // Same .tc-room skeleton as renderRoomRow (avatar/info/top/bottom) so a group row matches the // 1:1 rows visually — data-type="group" + data-gid instead of data-cid/data-uid is what the // click-delegation in renderRooms() switches on. No online/typing indicator (doesn't apply to // a group), and the avatar is a rounded square instead of a circle as the only visual cue // distinguishing a group from a person, mirroring common chat-app convention. function renderGroupRow(group) { var gid = group.gid; var name = group.name || ''; var lastMsg = group.lastMessage; var preview = ''; if (lastMsg) { preview = parseDisplayText(lastMsg.msm); if (!preview) { if (lastMsg.urlima && lastMsg.urlima.indexOf('public://') >= 0) preview = '📷'; else if (lastMsg.urlaudi && lastMsg.urlaudi.indexOf('public://') >= 0) preview = '🎵'; } if (String(lastMsg.whosend) === String(userId)) preview = 'You: ' + preview; } else { preview = 'No messages yet'; } var timeStr = group.updated ? fmtTime(unixToDbTimestamp(group.updated)) : ''; var unread = group.unreadCount > 0; var active = activeGid === gid; return '
' + '
' + escHtml((name || '?').charAt(0).toUpperCase()) + '
' + '
' + '
' + '' + escHtml(name) + '' + '' + escHtml(timeStr) + '' + '
' + '
' + '' + escHtml(preview) + '' + (unread ? '' : '') + '
' + '
' + '
'; } // ── Open a chat ────────────────────────────────────────────────────────────── function openChat(cid, uid, name) { activeCid = cid; activeGid = null; activeUid = uid; activeName = name; delete unreadCids[cid]; updateBadge(); $('#tc-chat-name').text(name); $('#tc-chat-online').text(''); $('#tc-img-btn').show(); $('#tc-rooms-view').addClass('tc-hidden'); $('#tc-chat-view').removeClass('tc-hidden'); renderMessages(cid); $('#tc-input').focus(); } // ── Open a group chat ──────────────────────────────────────────────────────── // Parallel to openChat() — group image sending goes through sendGroupImage() (socket-based, // no HTTP upload endpoint); audio still has no send control in this web widget. function openGroupChat(gid, name) { activeGid = gid; activeCid = null; activeUid = null; activeName = name; if (allGroups[gid]) allGroups[gid].unreadCount = 0; updateBadge(); $('#tc-chat-name').text(name); $('#tc-chat-online').text(''); $('#tc-img-btn').show(); $('#tc-rooms-view').addClass('tc-hidden'); $('#tc-chat-view').removeClass('tc-hidden'); renderGroupMessages(gid); $('#tc-input').focus(); if (socket && socket.connected) { var gidNum = parseInt(gid, 10); socket.emit('group-open', { gid: gidNum, uid: userId }); socket.emit('group-history', { gid: gidNum, uid: userId }); } } // ── Render messages ────────────────────────────────────────────────────────── function renderMessages(cid) { var msgs = allMsgs[cid] || []; var $el = $('#tc-msgs'); var order = msgs.slice().reverse(); $el.html(order.map(renderMsg).join('')); $el.scrollTop($el[0].scrollHeight); } function renderMsg(msg) { var isMe = String(msg.whosend) === String(userId); var side = isMe ? 'tc-out' : 'tc-in'; var parsed = parseInlineMeta(msg.msm || ''); var text = parsed.text; var meta = parsed.meta; var isSalt = (text.length === 18 && text.indexOf(' ') < 0); var metaHtml = ''; if (meta && meta.type === 'fwd') { metaHtml = '
↪ Forwarded from ' + escHtml(meta.sender) + '
'; } else if (meta && meta.type === 'reply') { metaHtml = '
' + '' + escHtml(meta.sender) + '' + '' + escHtml(meta.orig) + '' + '
'; } var body = ''; if (msg.urlima === '_loading_') { body += '
📷 Sending…
'; } else if (msg.urlima && msg.urlima.indexOf('public://') >= 0) { var src = msg.urlima.replace('public://', FILES_BASE); body += ''; } if (msg.urlaudi && msg.urlaudi.indexOf('public://') >= 0) { var asrc = msg.urlaudi.replace('public://', FILES_BASE); body += ''; } if (meta && meta.type === 'loc') { body += locationLinkHtml(meta.lat, meta.lng); } else if (!isSalt && text) { body += '
' + linkifyText(escHtml(text)) + '
'; } var time = fmtTime(msg.timestamp || ''); return '
' + metaHtml + body + '' + escHtml(time) + '' + '
'; } // ── Render group messages ──────────────────────────────────────────────────── function renderGroupMessages(gid) { var msgs = allGroupMsgs[gid] || []; var $el = $('#tc-msgs'); var order = msgs.slice().reverse(); $el.html(order.map(renderGroupMsg).join('')); $el.scrollTop($el[0].scrollHeight); } // Same shape as renderMsg(), plus a sender-name label on incoming bubbles — unlike a 1:1 // conversation, "not me" doesn't tell you who actually sent it. Reuses the identical // urlima/urlaudi rendering, so images/audio sent from the Android app (or sendGroupImage() // here) display correctly. Audio still has no send control in this web widget. function renderGroupMsg(msg) { var isMe = String(msg.whosend) === String(userId); var side = isMe ? 'tc-out' : 'tc-in'; var parsed = parseInlineMeta(msg.msm || ''); var text = parsed.text; var meta = parsed.meta; var isSalt = (text.length === 18 && text.indexOf(' ') < 0); var senderHtml = ''; if (!isMe) { senderHtml = '
' + escHtml(msg.senderName || msg.whosend) + '
'; } var metaHtml = ''; if (meta && meta.type === 'fwd') { metaHtml = '
↪ Forwarded from ' + escHtml(meta.sender) + '
'; } else if (meta && meta.type === 'reply') { metaHtml = '
' + '' + escHtml(meta.sender) + '' + '' + escHtml(meta.orig) + '' + '
'; } var body = ''; if (msg.urlima === '_loading_') { body += '
📷 Sending…
'; } else if (msg.urlima && msg.urlima.indexOf('public://') >= 0) { var src = msg.urlima.replace('public://', FILES_BASE); body += ''; } if (msg.urlaudi && msg.urlaudi.indexOf('public://') >= 0) { var asrc = msg.urlaudi.replace('public://', FILES_BASE); body += ''; } if (meta && meta.type === 'loc') { body += locationLinkHtml(meta.lat, meta.lng); } else if (!isSalt && text) { body += '
' + linkifyText(escHtml(text)) + '
'; } var time = fmtTime(unixToDbTimestamp(msg.created || 0)); return '
' + senderHtml + metaHtml + body + '' + escHtml(time) + '' + '
'; } // ── Send (opc 11) ──────────────────────────────────────────────────────────── function sendMessage() { var text = $.trim($('#tc-input').val()); if (!text || !activeCid) return; $('#tc-input').val('').focus(); isTyping = false; post({ opc: '11', userid: userId, cid: activeCid, msm: text, fid: '0' }) .then(function (resp) { if (!resp) return; var ok = (resp.resp === 3 || resp.resp === '3' || resp.resp === 11 || resp.resp === '11'); if (!ok) return; var msg = { id: resp.lastmid || String(Date.now()), whosend: String(userId), msm: text, urlima: '', urlaudi: '', timestamp: new Date().toISOString().replace('T', ' ').substr(0, 19) }; if (!allMsgs[activeCid]) allMsgs[activeCid] = []; allMsgs[activeCid].unshift(msg); renderMessages(activeCid); if (socket && socket.connected) { socket.emit('send-message', { id: resp.lastmid, chatId: activeCid, from: String(userId), to: String(activeUid), message: text, type: 'text' }); } }); } // ── Send group message ─────────────────────────────────────────────────────── // Purely socket-based — group-message-send both persists (tc_group_message, via the // signaling server) and delivers, unlike 1:1's HTTP-then-socket-notify split. The server // echoes the message back to the sender too (see group-message-send in signaling-server-2.js), // so onGroupSocketMessage() is what actually appends it here — no optimistic local insert. function sendGroupMessage() { var text = $.trim($('#tc-input').val()); if (!text || !activeGid) return; if (!socket || !socket.connected) return; $('#tc-input').val('').focus(); isTyping = false; socket.emit('group-message-send', { gid: parseInt(activeGid, 10), from: userId, message: text, type: 'text', localId: 'web-' + Date.now() + '-' + Math.random().toString(36).substr(2, 8) }); } // ── Send group image (group-message-send, type:"image") ───────────────────── // Unlike 1:1 (HTTP upload then socket notify), groups have no HTTP upload endpoint — // the server expects the raw file as a base64 string on the same socket event used for // text (see group-message-send in signaling-server-2.js), and does its own resize/compress // (sharp, 1024x1024 max, jpeg75) server-side before writing it and echoing "group-message" // back to every member including the sender. That echo is what onGroupSocketMessage() uses // to both confirm this optimistic placeholder (matched by localId) and actually display it. function sendGroupImage(file) { if (!activeGid) return; if (!socket || !socket.connected) return; if (pendingGroupImage) return; // one at a time — button is disabled meanwhile anyway var gid = activeGid; var localId = 'web-img-' + Date.now() + '-' + Math.random().toString(36).substr(2, 8); pendingGroupImage = { gid: gid, localId: localId }; if (!allGroupMsgs[gid]) allGroupMsgs[gid] = []; allGroupMsgs[gid].unshift({ id: localId, localId: localId, whosend: String(userId), senderName: myName, msm: '', urlima: '_loading_', urlaudi: '', created: Math.floor(Date.now() / 1000) }); renderGroupMessages(gid); $('#tc-img-btn').attr('disabled', 'disabled'); // Safety net — if the server/socket never answers at all (disconnect mid-flight etc.), // don't leave "Sending…" stuck forever (see the 1:1 image-send bug this mirrors). var timeoutId = setTimeout(function () { onGroupMessageError({}); }, 20000); compressImageIfNeeded(file, function (uploadFile) { var reader = new FileReader(); reader.onload = function () { clearTimeout(timeoutId); if (!pendingGroupImage || pendingGroupImage.localId !== localId) return; // already cleared var base64 = (reader.result || '').split(',')[1] || ''; if (!base64 || !socket || !socket.connected) { onGroupMessageError({}); return; } socket.emit('group-message-send', { gid: parseInt(gid, 10), from: userId, message: '', type: 'image', filedata: base64, localId: localId }); }; reader.onerror = function () { clearTimeout(timeoutId); onGroupMessageError({}); }; reader.readAsDataURL(uploadFile); }); } // Clears whatever group image send is currently in flight — used both for explicit // "group-message-error" replies (bad file, not a member, rate-limited) and the // client-side timeout above. Doesn't distinguish by localId: only one send is ever // in flight at a time (the button is disabled meanwhile), so there's nothing to match. function onGroupMessageError(data) { if (!pendingGroupImage) return; var gid = pendingGroupImage.gid; var localId = pendingGroupImage.localId; pendingGroupImage = null; $('#tc-img-btn').removeAttr('disabled'); if (allGroupMsgs[gid]) { allGroupMsgs[gid] = allGroupMsgs[gid].filter(function (m) { return m.localId !== localId; }); } if (activeGid === gid && isOpen) renderGroupMessages(gid); if (data && data.error) { console.warn('group image send failed:', data.error); } } // Resizes/re-encodes as JPEG when a file exceeds COMPRESS_THRESHOLD. Skips GIFs — // redrawing to canvas would flatten an animated GIF to a single static frame. // Falls back to the original file untouched if decoding/encoding fails for any reason. function compressImageIfNeeded(file, callback) { if (file.size <= COMPRESS_THRESHOLD || file.type === 'image/gif') { callback(file); return; } var img = new Image(); var url = URL.createObjectURL(file); img.onload = function () { URL.revokeObjectURL(url); var scale = Math.min(1, COMPRESS_MAX_DIM / Math.max(img.width, img.height)); var canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); canvas.toBlob(function (blob) { callback(blob || file, !!blob); }, 'image/jpeg', COMPRESS_QUALITY); }; img.onerror = function () { URL.revokeObjectURL(url); callback(file); }; img.src = url; } // ── Send image (opc 11I) ───────────────────────────────────────────────────── function sendImage(file) { if (!activeCid) return; var tempId = '_img_' + Date.now(); if (!allMsgs[activeCid]) allMsgs[activeCid] = []; allMsgs[activeCid].unshift({ id: tempId, whosend: String(userId), msm: '', urlima: '_loading_', urlaudi: '', timestamp: new Date().toISOString().replace('T', ' ').substr(0, 19) }); renderMessages(activeCid); // .attr()/.removeAttr(), not .prop() — this site's jQuery predates 1.6 (which is // when .prop() was introduced), and every other call in this file sticks to // pre-1.6-safe APIs already. $('#tc-img-btn').attr('disabled', 'disabled'); compressImageIfNeeded(file, function (uploadFile, wasCompressed) { var form = new FormData(); form.append('opc', '11I'); form.append('userid', String(userId)); form.append('cid', activeCid); form.append('image', uploadFile, wasCompressed ? 'upload.jpg' : file.name); fetch(API_URL, { method: 'POST', body: form }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (resp) { allMsgs[activeCid] = allMsgs[activeCid].filter(function (m) { return m.id !== tempId; }); if (resp && (resp.resp === 3 || resp.resp === '3') && resp.urlima) { var msg = { id: resp.lastmid || String(Date.now()), whosend: String(userId), msm: '', urlima: resp.urlima, urlaudi: '', timestamp: new Date().toISOString().replace('T', ' ').substr(0, 19) }; allMsgs[activeCid].unshift(msg); if (socket && socket.connected) { socket.emit('send-message', { id: resp.lastmid, chatId: activeCid, from: String(userId), to: String(activeUid), message: '', urlima: resp.urlima, type: 'image' }); } } renderMessages(activeCid); }) .catch(function () { allMsgs[activeCid] = allMsgs[activeCid].filter(function (m) { return m.id !== tempId; }); renderMessages(activeCid); }) .then(function () { $('#tc-img-btn').removeAttr('disabled'); }); }); } // ── Inline meta parser ──────────────────────────────────────────────────────── function parseInlineMeta(raw) { if (!raw) return { text: '', meta: null }; if (raw.substr(0, FWD_MARKER.length) === FWD_MARKER) { var end = raw.indexOf(META_ETX); if (end < 0) return { text: raw, meta: null }; var sender = raw.substring(FWD_MARKER.length, end); return { text: raw.substring(end + 1), meta: { type: 'fwd', sender: sender } }; } if (raw.substr(0, REPLY_MARKER.length) === REPLY_MARKER) { var rend = raw.indexOf(META_ETX); if (rend < 0) return { text: raw, meta: null }; var parts = raw.substring(REPLY_MARKER.length, rend); var sep = parts.indexOf(META_SEP); var rsender = sep >= 0 ? parts.substring(0, sep) : parts; var orig = sep >= 0 ? parts.substring(sep + 1) : ''; return { text: raw.substring(rend + 1), meta: { type: 'reply', sender: rsender, orig: orig } }; } // Shared location — same "lat" + META_SEP + "lng" encoding the Android app writes // (see LOC_MARKER / sendLocationMessage() in ActivityChat.java). No trailing text. if (raw.substr(0, LOC_MARKER.length) === LOC_MARKER) { var lend = raw.indexOf(META_ETX); if (lend < 0) return { text: raw, meta: null }; var coords = raw.substring(LOC_MARKER.length, lend); var lsep = coords.indexOf(META_SEP); var lat = lsep >= 0 ? coords.substring(0, lsep) : coords; var lng = lsep >= 0 ? coords.substring(lsep + 1) : ''; return { text: raw.substring(lend + 1), meta: { type: 'loc', lat: lat, lng: lng } }; } return { text: raw, meta: null }; } function parseDisplayText(msm) { var p = parseInlineMeta(msm || ''); var t = p.text; if (p.meta && p.meta.type === 'loc') return '📍 ' + LOC_LABEL; if (t.length === 18 && t.indexOf(' ') < 0) return ''; return (p.meta ? (p.meta.type === 'fwd' ? '↪ ' : '↩ ') : '') + t; } // ── Badge & notifications ───────────────────────────────────────────────────── function updateBadge() { var n = Object.keys(unreadCids).length; Object.keys(allGroups).forEach(function (k) { if (allGroups[k].unreadCount > 0) n++; }); var $b = $('#tc-badge'); if (n > 0) { $b.text(n).removeClass('tc-hidden'); } else { $b.addClass('tc-hidden'); } } function showNotif(title, body) { if (!('Notification' in window)) return; if (Notification.permission === 'granted') { new Notification(title, { body: body || '', icon: 'https://www.taleoi.com/sites/default/files/taleoi.ico' }); } else if (Notification.permission === 'default') { Notification.requestPermission(); } } // ── HTTP ────────────────────────────────────────────────────────────────────── function post(params) { var form = new FormData(); Object.keys(params).forEach(function (k) { form.append(k, String(params[k])); }); return fetch(API_URL, { method: 'POST', body: form }) .then(function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }); } // ── Helpers ─────────────────────────────────────────────────────────────────── // Group timestamps (tc_group.updated, tc_group_message.created) are unix seconds, unlike // 1:1's "YYYY-MM-DD HH:MM:SS" strings — converts to that same shape (local time) so both can // share fmtTime() and the plain string comparison renderRooms() sorts by. // Formatted with UTC getters, not local — fmtTime() (below) always treats this // "YYYY-MM-DD HH:MM:SS" shape as UTC (appending 'Z' before parsing), matching the raw // DB timestamp strings 1:1 chat passes it directly. Using local getters here would hand // fmtTime() an already-local time it then re-shifts by the viewer's UTC offset again. function unixToDbTimestamp(unixSec) { if (!unixSec) return '0'; var d = new Date(unixSec * 1000); function pad(n) { return (n < 10 ? '0' : '') + n; } return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) + ' ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()); } function fmtTime(ts) { if (!ts) return ''; // DB timestamps are plain "YYYY-MM-DD HH:MM:SS" in UTC (MySQL TIMESTAMP column). // Without a trailing 'Z', the JS Date parser treats a date-TIME string (unlike a // date-only string) as local time, not UTC — silently shifting displayed times by // the viewer's UTC offset. The 'Z' forces correct UTC interpretation. var d = new Date(ts.replace(' ', 'T') + 'Z'); if (isNaN(d.getTime())) return ''; var now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return (d.getMonth() + 1) + '/' + d.getDate(); } function escHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function escAttr(s) { return escHtml(s); } // Wraps http(s) URLs in a message's already-escaped text with clickable links. Must run // AFTER escHtml() — matching against the escaped string is safe since escaping never // introduces whitespace inside a URL run, and the entities it does introduce (e.g. "&" // for a literal "&" in a query string) are exactly what a browser expects inside an href // attribute anyway. target="_blank" + rel="noopener noreferrer" so following a link never // navigates away from (or exposes window.opener to) the page the chat widget is floating on. function linkifyText(escapedText) { return escapedText.replace(/https?:\/\/[^\s<]+/g, function (url) { var trail = ''; var m = url.match(/[.,!?:;)\]}'"]+$/); if (m) { trail = m[0]; url = url.slice(0, -trail.length); } if (!url) return trail; return '' + url + '' + trail; }); } // A shared location (LOC_MARKER — see parseInlineMeta()) renders as a tappable card // linking to Google Maps, matching how the Android app opens a geo: intent for the // same coordinates. Uses an https:// maps URL instead since geo: isn't reliably // clickable in desktop browsers. function locationLinkHtml(lat, lng) { var mapUrl = 'https://www.google.com/maps?q=' + encodeURIComponent(lat) + ',' + encodeURIComponent(lng); return '' + '📍 ' + escHtml(LOC_LABEL) + ''; } })(jQuery); Reports | Page 18 | taleoi.com

Social Tabatinga Leticia Iquitos - TALEOI.COM

Taleoi
World World
Video Video
Inicio Inicio
Market Market
News News

Reports

video-1755452453

  • up
    100%
  • down
    0%
  • Read more about video-1755452453
  • Log in or register to post comments

video-1755435345

  • up
    50%
  • down
    50%
  • Read more about video-1755435345
  • Log in or register to post comments

video-1755345839

  • up
    50%
  • down
    50%
  • Read more about video-1755345839
  • Log in or register to post comments

video-1755031587

  • up
    50%
  • down
    50%
  • Read more about video-1755031587
  • Log in or register to post comments

video-1755030713

  • up
    50%
  • down
    50%
  • Read more about video-1755030713
  • Log in or register to post comments

video-1755008391

  • up
    50%
  • down
    50%
  • Read more about video-1755008391
  • Log in or register to post comments

video-1755006284

  • up
    50%
  • down
    50%
  • Read more about video-1755006284
  • Log in or register to post comments

video-1755003648

  • up
    50%
  • down
    50%
  • Read more about video-1755003648
  • Log in or register to post comments

video-1754940699

  • up
    50%
  • down
    50%
  • Read more about video-1754940699
  • Log in or register to post comments

video-1754940464

  • up
    100%
  • down
    0%
  • Read more about video-1754940464
  • Log in or register to post comments

Pages

  • « first
  • ‹ previous
  • …
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • …
  • next ›
  • last »
Subscribe to RSS - Reports