';
}
// 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 '
';
}
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 = '
';
}
// ── 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);
Pudiera yo gritar | taleoi.com
Desde hace más de 20 años que esta fecha ha sido conmemorado en mis más recónditas reflexiones, una fecha muy especial, no solo porque mi lista de admiración y aprecio hacia las mujeres son inmensurables, sino de la fuerza que ellas inspiran para que seamos verdaderamente felices; pudiera yo gritar a los cuatro vientos, pero mi imaginación hace traspasar mi grito al infinito. Más allá de ser una fecha conmemorativa de una tragedia a fines del siglo XX; esta mecha que se encendió en Estados unidos y Europa, voces y movimientos en defensa a los derechos de la mujer con un desenlace trágico en una fábrica de textil, voces que se perdieron en el limbo de las almas calcinadas, lamento, sollozos que se atragantaron en las familias , huérfanos y viudos a través del tiempo y en la paradojas de gemelos vuelven para decirnos que esto no debe volver a suceder, ni en el tiempo, ni en el destino de la humanidad.
Este 8 de marzo como los que vendrán y como todas las fechas, cuando veas la sonrisa de una mujer, detrás de esa sonrisa guarda muchos sentimientos como en una caja de pandora que solo un alma limpia puede abrir o con una llave maestra que solo los corazones con secretos sabe abrir, hoy es una buena ocasión para reflexionar sobre lo construido por cada una de ellas, desde tu vida y sus vidas: pasado, presente y futuro, pero más allá casi todos tenemos una deuda en materia de derechos e igualdad de género. ¡Vaya deuda¡ entonces démosle el paso para la igualdad de género, ellas lo merecen todo. Esta deuda es casi difícil de saldar porque aún nos devora el egoísmo de hombre para que ellas superen las dificultades que las impiden progresar, a mujeres y a niñas, a alcanzar su pleno potencial. Potencial que no se ve por la ceguera que padecemos muchos entre hombres y mujeres que yo pudiera gritar; que de alguna manera hasta sutil, genera violencia de género, es hora de erradicar esta tan cruel realidad y fomentar la participación de las mujeres en la toma de decisiones, la inversión en acciones con creatividad en campañas educativas a favor de la igualdad. Oh mujer eres todo en mi ser y sin ti no soy nada, a lo mejor no rime lo que profeso por ustedes en esta corta nota de homenaje, de mis letras hechas amor. Por eso pudiera yo gritar.
Empoderamiento es necesario más allá de sus habilidades y conocimientos, y se las reconoce por ello, autoestima debes incentivar en ellas, sin decirlo, un solo gesto de amor es suficiente, solucionando sus problemas no aumentándolas, a veces es necesario las mejores palabras para despertar una Amazonas en tu lado a la conquista del mundo. Cuán lejos estamos de la revalorización positiva para trascender las desigualdades que aún imperantes en muchas comunidades. Mujer tu espacio no solo está en mi corazón, está en mis ojos, en mi boca y en lo que sale de ella, palabras para decirles cuanto las amo eso pudiera yo gritar.
Un largo camino se está por recorrer desde aquellos movimientos sociales del siglo XX pero aún falta muchos kilómetros de trabajo y energía que gastar y andar. “Las mujeres representan el 60% de los más pobres a nivel mundial, las dos terceras partes de los analfabetos y son objeto de violencia de manera sistemática”.
Sé un agente de cambio, a través de tu actitud, de tu liderazgo y del ejemplo que puedas trasmitir. Esa cajita de pandora espera por ti, solo usa la verdadera llave la que abre tu mismo corazón.
Pudiera yo gritar que el Día de la Mujer sea un día de reflexión y todo el año sea de acción para que hombres y mujeres podamos convivir en armonía en sociedades justas, armoniosas y amorosas. A todas mis amigas, que no se quede ninguna por fuera de mi extensa lista de las mujeres que amo, con sus disgustos, sus mal genios, sus malos días, hasta las más vulgares, pero también y sobre todo aquella que con su sonrisa me alegran mi caminar; todas son hermosas y encajan a mi corazón.