buhapp-telegram/public/js/views/chat.js
root 7fa2dc83e4 UX fixes: auto geolocation, read-ticks, pulse animation
- Map: auto-request geolocation on view enter (no manual click needed)
- Map: pulse animation on 'Share location' button (calls attention)
- Map: better error message on permission denied (with link to settings)
- Map: 'loading...' state on radius change
- Chat: ✓ / ✓✓ read marks on own messages (from m.read_at)
- Chat: WS handler for 'message_read' event (real-time update)
- Chat: enterkeyhint='send' on input (mobile keyboard send button)
- Chat: send on Enter key (no shift)
- CSS: btn-pulse animation
- CSS: read-tick styles
2026-08-20 20:45:27 +00:00

179 lines
6.3 KiB
JavaScript

// Single chat view
const ChatView = {
chatId: null,
otherId: null,
otherName: null,
ws: null,
async render(root, params) {
this.chatId = params.chatId;
this.otherId = params.otherId;
this.otherName = params.otherName;
root.innerHTML = `
<div class="view" style="display:flex;flex-direction:column;height:calc(100vh - 100px);padding:0;">
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px;">
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-back">←</button>
<div class="name" style="flex:1;font-weight:600;">${escapeHtml(this.otherName || '')}</div>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-profile">👤</button>
<button class="btn ghost" style="width:auto;margin:0;padding:6px 10px;" id="chat-review">⭐</button>
</div>
<div id="messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;"></div>
<div id="edit-bar" style="display:none;padding:6px 12px;background:var(--bg-elev);border-top:1px solid var(--border);font-size:13px;">
<span>Редактирование</span>
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;">Отмена</button>
</div>
<div class="chat-input">
<input id="msg-input" class="input" placeholder="Сообщение..." autocomplete="off" enterkeyhint="send" style="margin:0;">
<button class="btn" id="msg-send">➤</button>
</div>
</div>
`;
document.getElementById('chat-back').onclick = () => App.showView('chats');
document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName);
document.getElementById('chat-review').onclick = () => App.openReview(this.chatId, this.otherId, this.otherName);
document.getElementById('msg-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.send();
}
});
document.getElementById('msg-send').onclick = () => this.send();
this.loadMessages();
this.setupWS();
this.markRead();
},
async loadMessages() {
try {
const r = await API.ChatApi.listMessages(this.chatId);
const box = document.getElementById('messages');
box.innerHTML = '';
r.messages.forEach((m) => this.appendMessage(m));
box.scrollTop = box.scrollHeight;
} catch (e) {
console.warn(e);
}
},
appendMessage(m) {
const box = document.getElementById('messages');
const me = API.Store.user && m.sender_id === API.Store.user.id;
const div = document.createElement('div');
div.className = 'bubble ' + (me ? 'mine' : 'theirs');
div.dataset.id = m.id;
if (m.deleted) {
div.innerHTML = '<em>Удалено</em>';
} else {
// ✓✓ если прочитано собеседником, ✓ если только доставлено
const readMark = me
? (m.read_at ? '<span class="read-tick read">✓✓</span>' : '<span class="read-tick">✓</span>')
: '';
div.innerHTML = `
<div>${escapeHtml(m.body)}</div>
<div class="meta">
<span>${formatTime(m.created_at)}</span>
${m.edited ? '<span class="edited">ред.</span>' : ''}
${readMark}
</div>
`;
if (me) {
div.onclick = () => this.onMessageClick(m);
}
}
box.appendChild(div);
box.scrollTop = box.scrollHeight;
},
onMessageClick(m) {
TG_APP.showConfirm('Удалить сообщение?').then((ok) => {
if (ok) this.deleteMessage(m);
});
},
async deleteMessage(m) {
try {
await API.ChatApi.deleteMessage(m.id);
const el = document.querySelector(`.bubble[data-id="${m.id}"]`);
if (el) el.innerHTML = '<em>Удалено</em>';
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
},
setupWS() {
if (!API.Store.access) return;
const url = (window.API_BASE || 'https://api.buhapp.mygoodservice.ru').replace(/^http/, 'ws') + '/ws?token=' + API.Store.access;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => console.log('WS connected');
this.ws.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.type === 'message' && d.payload.chat_id === this.chatId) {
this.appendMessage(d.payload);
} else if (d.type === 'message_edited' && d.payload.chat_id === this.chatId) {
this.updateMessage(d.payload);
} else if (d.type === 'message_read' && d.payload.chat_id === this.chatId) {
this.markMessagesRead();
}
} catch {}
};
this.ws.onclose = () => setTimeout(() => this.setupWS(), 3000);
this.ws.onerror = (e) => console.warn('WS error', e);
} catch (e) {
console.warn(e);
}
},
async markMessagesRead() {
try {
const r = await API.ChatApi.listMessages(this.chatId);
r.messages.forEach((m) => {
if (m.read_at) {
const el = document.querySelector(`.bubble[data-id="${m.id}"] .read-tick`);
if (el) { el.classList.add('read'); el.textContent = '✓✓'; }
}
});
} catch {}
},
updateMessage(p) {
const el = document.querySelector(`.bubble[data-id="${p.id}"]`);
if (el) {
el.querySelector('div:first-child') && (el.querySelector('div:first-child').textContent = p.body);
const meta = el.querySelector('.meta');
if (meta && !meta.querySelector('.edited')) {
const ed = document.createElement('span');
ed.className = 'edited';
ed.textContent = 'ред.';
meta.appendChild(ed);
}
}
},
async send() {
const inp = document.getElementById('msg-input');
const body = inp.value.trim();
if (!body) return;
inp.value = '';
try {
await API.ChatApi.sendMessage(this.chatId, body);
} catch (e) {
TG_APP.showAlert('Ошибка: ' + e.message);
}
},
async markRead() {
try { await API.ChatApi.markRead(this.chatId); } catch {}
},
cleanup() {
if (this.ws) { this.ws.close(); this.ws = null; }
},
};
window.ChatView = ChatView;