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
This commit is contained in:
parent
149f31d25d
commit
7fa2dc83e4
@ -483,3 +483,20 @@ label { font-size: 13px; color: var(--text-dim); margin-bottom: 4px; display: bl
|
|||||||
.onboarding-dot { animation: none !important; transition: none !important; }
|
.onboarding-dot { animation: none !important; transition: none !important; }
|
||||||
.onboarding-slide { transform: none; opacity: 1; pointer-events: auto; }
|
.onboarding-slide { transform: none; opacity: 1; pointer-events: auto; }
|
||||||
}
|
}
|
||||||
|
/* Pulse animation для важных кнопок (позвать на действие) */
|
||||||
|
.btn-pulse {
|
||||||
|
display: inline-block;
|
||||||
|
animation: btn-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes btn-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-marks для чата */
|
||||||
|
.bubble .meta { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bubble .read-tick { font-size: 12px; opacity: 0.85; }
|
||||||
|
.bubble .read-tick.read { color: var(--secondary); font-weight: 700; }
|
||||||
|
|
||||||
|
/* edit marker */
|
||||||
|
.bubble .edited { font-style: italic; opacity: 0.7; }
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const ChatView = {
|
|||||||
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;">Отмена</button>
|
<button id="edit-cancel" style="float:right;background:none;border:none;color:var(--danger);font-weight:600;">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input">
|
<div class="chat-input">
|
||||||
<input id="msg-input" class="input" placeholder="Сообщение..." style="margin:0;">
|
<input id="msg-input" class="input" placeholder="Сообщение..." autocomplete="off" enterkeyhint="send" style="margin:0;">
|
||||||
<button class="btn" id="msg-send">➤</button>
|
<button class="btn" id="msg-send">➤</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -33,6 +33,14 @@ const ChatView = {
|
|||||||
document.getElementById('chat-profile').onclick = () => App.openUserProfile(this.otherId, this.otherName);
|
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('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.loadMessages();
|
||||||
this.setupWS();
|
this.setupWS();
|
||||||
this.markRead();
|
this.markRead();
|
||||||
@ -59,11 +67,16 @@ const ChatView = {
|
|||||||
if (m.deleted) {
|
if (m.deleted) {
|
||||||
div.innerHTML = '<em>Удалено</em>';
|
div.innerHTML = '<em>Удалено</em>';
|
||||||
} else {
|
} else {
|
||||||
|
// ✓✓ если прочитано собеседником, ✓ если только доставлено
|
||||||
|
const readMark = me
|
||||||
|
? (m.read_at ? '<span class="read-tick read">✓✓</span>' : '<span class="read-tick">✓</span>')
|
||||||
|
: '';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div>${escapeHtml(m.body)}</div>
|
<div>${escapeHtml(m.body)}</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>${formatTime(m.created_at)}</span>
|
<span>${formatTime(m.created_at)}</span>
|
||||||
${m.edited ? '<span class="edited">ред.</span>' : ''}
|
${m.edited ? '<span class="edited">ред.</span>' : ''}
|
||||||
|
${readMark}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
if (me) {
|
if (me) {
|
||||||
@ -103,6 +116,8 @@ const ChatView = {
|
|||||||
this.appendMessage(d.payload);
|
this.appendMessage(d.payload);
|
||||||
} else if (d.type === 'message_edited' && d.payload.chat_id === this.chatId) {
|
} else if (d.type === 'message_edited' && d.payload.chat_id === this.chatId) {
|
||||||
this.updateMessage(d.payload);
|
this.updateMessage(d.payload);
|
||||||
|
} else if (d.type === 'message_read' && d.payload.chat_id === this.chatId) {
|
||||||
|
this.markMessagesRead();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
@ -113,6 +128,18 @@ const ChatView = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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) {
|
updateMessage(p) {
|
||||||
const el = document.querySelector(`.bubble[data-id="${p.id}"]`);
|
const el = document.querySelector(`.bubble[data-id="${p.id}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
@ -134,7 +161,6 @@ const ChatView = {
|
|||||||
inp.value = '';
|
inp.value = '';
|
||||||
try {
|
try {
|
||||||
await API.ChatApi.sendMessage(this.chatId, body);
|
await API.ChatApi.sendMessage(this.chatId, body);
|
||||||
// WS пришлёт обратно
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
TG_APP.showAlert('Ошибка: ' + e.message);
|
TG_APP.showAlert('Ошибка: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,9 @@ const MapView = {
|
|||||||
<span class="slider"></span>
|
<span class="slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" id="loc-share-btn" style="margin-top:8px;">📍 Поделиться геолокацией</button>
|
<button class="btn" id="loc-share-btn" style="margin-top:8px;">
|
||||||
|
<span class="btn-pulse">📍</span> Поделиться геолокацией
|
||||||
|
</button>
|
||||||
<div class="privacy-note" id="loc-status">Нажми чтобы получить координаты</div>
|
<div class="privacy-note" id="loc-status">Нажми чтобы получить координаты</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map" style="height:400px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
|
<div id="map" style="height:400px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
|
||||||
@ -52,17 +54,21 @@ const MapView = {
|
|||||||
this.visible = e.target.checked;
|
this.visible = e.target.checked;
|
||||||
try {
|
try {
|
||||||
await API.Auth.setVisibility(this.visible);
|
await API.Auth.setVisibility(this.visible);
|
||||||
this.refreshLocation();
|
if (window._lastKnownPos) this.applyPosition(window._lastKnownPos);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
API.Store && console.warn(err);
|
console.warn(err);
|
||||||
TG_APP.showAlert('Не удалось обновить видимость');
|
TG_APP.showAlert('Не удалось обновить видимость');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('radius-sel').addEventListener('change', (e) => {
|
document.getElementById('radius-sel').addEventListener('change', async (e) => {
|
||||||
this.radius = parseInt(e.target.value, 10);
|
this.radius = parseInt(e.target.value, 10);
|
||||||
document.getElementById('radius-label').textContent = this.radius + ' км';
|
document.getElementById('radius-label').textContent = this.radius + ' км';
|
||||||
this.refreshNearby();
|
const c = document.getElementById('nearby-count');
|
||||||
|
if (c) c.textContent = '⏳ поиск...';
|
||||||
|
if (window._lastKnownPos) {
|
||||||
|
await this.refreshNearbyFromPos(window._lastKnownPos.coords.latitude, window._lastKnownPos.coords.longitude);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('loc-share-btn').onclick = (e) => {
|
document.getElementById('loc-share-btn').onclick = (e) => {
|
||||||
@ -73,6 +79,10 @@ const MapView = {
|
|||||||
// Если уже есть сохранённые координаты — покажем на карте
|
// Если уже есть сохранённые координаты — покажем на карте
|
||||||
if (window._lastKnownPos) {
|
if (window._lastKnownPos) {
|
||||||
this.applyPosition(window._lastKnownPos);
|
this.applyPosition(window._lastKnownPos);
|
||||||
|
} else {
|
||||||
|
// Авто-запрос геолокации при первом заходе на карту
|
||||||
|
// Не подвешиваем UI — requestLocation вернётся мгновенно, а браузер сам спросит у пользователя
|
||||||
|
this.requestLocation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -90,13 +100,24 @@ const MapView = {
|
|||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
async (pos) => {
|
async (pos) => {
|
||||||
window._lastKnownPos = pos;
|
window._lastKnownPos = pos;
|
||||||
if (btn) { btn.disabled = false; btn.textContent = '📍 Обновить геолокацию'; }
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<span class="btn-pulse">📍</span> Обновить геолокацию';
|
||||||
|
}
|
||||||
if (status) status.textContent = `✓ ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
|
if (status) status.textContent = `✓ ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
|
||||||
await this.applyPosition(pos);
|
await this.applyPosition(pos);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = '📍 Попробовать снова'; }
|
if (btn) {
|
||||||
if (status) status.textContent = '⚠️ ' + this.geolocationErrorText(err);
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<span class="btn-pulse">📍</span> Попробовать снова';
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
const msg = this.geolocationErrorText(err);
|
||||||
|
status.innerHTML = '⚠️ ' + msg + (err.code === 1
|
||||||
|
? ' <br><small>Открой Настройки → Сайты → Геолокация → buhapp.mygoodservice.ru</small>'
|
||||||
|
: '');
|
||||||
|
}
|
||||||
console.warn('geolocation error:', err);
|
console.warn('geolocation error:', err);
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }
|
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }
|
||||||
@ -105,7 +126,7 @@ const MapView = {
|
|||||||
|
|
||||||
geolocationErrorText(err) {
|
geolocationErrorText(err) {
|
||||||
switch (err.code) {
|
switch (err.code) {
|
||||||
case err.PERMISSION_DENIED: return 'Доступ к геолокации запрещён. Разреши в настройках браузера.';
|
case err.PERMISSION_DENIED: return 'Доступ к геолокации запрещён.';
|
||||||
case err.POSITION_UNAVAILABLE: return 'Не удалось определить координаты (нет GPS/Wi-Fi).';
|
case err.POSITION_UNAVAILABLE: return 'Не удалось определить координаты (нет GPS/Wi-Fi).';
|
||||||
case err.TIMEOUT: return 'Таймаут запроса геолокации.';
|
case err.TIMEOUT: return 'Таймаут запроса геолокации.';
|
||||||
default: return 'Неизвестная ошибка геолокации.';
|
default: return 'Неизвестная ошибка геолокации.';
|
||||||
@ -164,6 +185,8 @@ const MapView = {
|
|||||||
this.markers.push(m);
|
this.markers.push(m);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const c = document.getElementById('nearby-count');
|
||||||
|
if (c) c.textContent = '📍 нужна геолокация';
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user