📋 Что получится
Универсально: Примеры бэкенда на PHP, но паттерн работает с любым языком — Node.js, Python, Go и др.
Алиса онлайн
Алиса печатает...
- Показывает, что собеседник набирает сообщение
- Throttling — не больше 1 запроса в 2 секунды
- Автоматически скрывается через 3 секунды
- Не показывается отправителю
🏗️ Как это работает
1
Пользователь начинает печатать
↓
2
Клиент отправляет событие "typing" (throttle: 2 сек)
↓
3
Pushler доставляет событие собеседникам
↓
4
Индикатор показывается на 3 секунды
↓
5
Если нет новых событий — скрывается
Важно:
- Не отправлять событие на каждое нажатие клавиши (throttle)
- Автоматически скрывать индикатор через таймаут
- Не показывать индикатор отправителю
💻 Код сервера
Сервер получает событие "печатает" и транслирует его через Pushler. Примеры на PHP — легко адаптировать под любой язык.
Установка PHP SDK
Bash
composer require pushler/php-sdk
Обработка события "печатает"
PHP
<?php
// typing.php — обработка события "пользователь печатает"
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
// Инициализация Pushler
$pushler = new PushlerClient(
'key_ваш_ключ', // App Key
'secret_ваш_секрет' // App Secret
);
// Получаем данные
$chatId = (int) $_POST['chat_id'];
$socketId = $_POST['socket_id'] ?? null;
$userId = getCurrentUserId(); // Ваша функция авторизации
// Проверяем, что пользователь — участник чата
if (!userHasAccessToChat($userId, $chatId)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
// Получаем данные пользователя
$user = getUserById($userId);
// Отправляем событие в канал чата
// ⚡ Передаём socket_id чтобы исключить отправителя
$pushler->trigger(
"private-chat-{$chatId}",
'user:typing',
[
'user_id' => $userId,
'user_name' => $user['name'],
'timestamp' => date('c')
],
$socketId // Исключаем отправителя
);
echo json_encode(['success' => true]);
Endpoint авторизации канала
PHP
<?php
// auth.php — авторизация приватных каналов
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
header('Content-Type: application/json');
$channelName = $_POST['channel_name'];
$socketId = $_POST['socket_id'];
$userId = getCurrentUserId();
// Проверяем авторизацию
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// Проверяем доступ к каналу private-chat-{id}
if (preg_match('/^private-chat-(\d+)$/', $channelName, $matches)) {
$chatId = (int) $matches[1];
if (!userHasAccessToChat($userId, $chatId)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
}
// Генерируем авторизацию
$auth = $pushler->authorizeChannel($channelName, $socketId);
echo json_encode($auth);
🌐 Код клиента (JavaScript)
Vanilla JavaScript — класс TypingIndicator
JavaScript
/**
* Класс для управления индикатором "печатает..."
*/
class TypingIndicator {
constructor(pushler, chatId, currentUserId) {
this.pushler = pushler;
this.chatId = chatId;
this.currentUserId = currentUserId;
// Состояние
this.typingUsers = new Map(); // userId -> { name, timeout }
this.lastTypingSent = 0;
this.THROTTLE_MS = 2000; // Отправлять не чаще раза в 2 сек
this.DISPLAY_MS = 3000; // Показывать индикатор 3 сек
// Callback для обновления UI
this.onTypingChange = null;
this.subscribeToEvents();
}
subscribeToEvents() {
const channel = this.pushler.channel(`private-chat-${this.chatId}`);
// Кто-то печатает
channel.on('user:typing', (data) => {
// Игнорируем свои события
if (data.user_id === this.currentUserId) return;
this.handleTyping(data);
});
// Когда приходит сообщение — убираем индикатор
channel.on('message:new', (data) => {
this.clearTyping(data.message.user_id);
});
}
handleTyping(data) {
const { user_id, user_name } = data;
// Очищаем предыдущий таймаут
const existing = this.typingUsers.get(user_id);
if (existing?.timeout) {
clearTimeout(existing.timeout);
}
// Устанавливаем новый таймаут
const timeout = setTimeout(() => {
this.clearTyping(user_id);
}, this.DISPLAY_MS);
this.typingUsers.set(user_id, { name: user_name, timeout });
this.notifyChange();
}
clearTyping(userId) {
const user = this.typingUsers.get(userId);
if (user?.timeout) clearTimeout(user.timeout);
this.typingUsers.delete(userId);
this.notifyChange();
}
notifyChange() {
if (this.onTypingChange) {
const users = Array.from(this.typingUsers.values()).map(u => u.name);
this.onTypingChange(users);
}
}
// Вызывать при вводе текста
sendTyping() {
const now = Date.now();
if (now - this.lastTypingSent < this.THROTTLE_MS) return;
this.lastTypingSent = now;
fetch('/typing.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `chat_id=${this.chatId}&socket_id=${this.pushler.socketId}`
});
}
// Форматирование текста
getDisplayText() {
const names = Array.from(this.typingUsers.values()).map(u => u.name);
if (names.length === 0) return null;
if (names.length === 1) return `${names[0]} печатает...`;
if (names.length === 2) return `${names[0]} и ${names[1]} печатают...`;
return `${names[0]} и ещё ${names.length - 1} печатают...`;
}
destroy() {
for (const user of this.typingUsers.values()) {
if (user.timeout) clearTimeout(user.timeout);
}
this.typingUsers.clear();
}
}
// === Использование ===
const pushler = new PushlerClient({
appKey: 'key_ваш_ключ',
authEndpoint: '/auth.php'
});
pushler.on('connected', () => {
const channel = pushler.subscribe(`private-chat-${chatId}`);
const typing = new TypingIndicator(pushler, chatId, currentUserId);
// Обновляем UI при изменении
typing.onTypingChange = (users) => {
const indicator = document.getElementById('typing-indicator');
const text = typing.getDisplayText();
if (text) {
indicator.textContent = text;
indicator.style.display = 'block';
} else {
indicator.style.display = 'none';
}
};
// При вводе текста
document.getElementById('message-input').addEventListener('input', () => {
typing.sendTyping();
});
});
Vue 3 Composable
JavaScript
// composables/useTypingIndicator.js
import { ref, computed, onMounted, onUnmounted } from 'vue';
export function useTypingIndicator(pushler, chatId, currentUserId) {
const typingUsers = ref(new Map());
const THROTTLE_MS = 2000;
const DISPLAY_MS = 3000;
let lastTypingSent = 0;
let channel = null;
const displayText = computed(() => {
const names = Array.from(typingUsers.value.values()).map(u => u.name);
if (names.length === 0) return null;
if (names.length === 1) return `${names[0]} печатает...`;
if (names.length === 2) return `${names[0]} и ${names[1]} печатают...`;
return `${names[0]} и ещё ${names.length - 1} печатают...`;
});
const isTyping = computed(() => typingUsers.value.size > 0);
function handleTyping(data) {
if (data.user_id === currentUserId) return;
const existing = typingUsers.value.get(data.user_id);
if (existing?.timeout) clearTimeout(existing.timeout);
const timeout = setTimeout(() => clearTyping(data.user_id), DISPLAY_MS);
typingUsers.value.set(data.user_id, { name: data.user_name, timeout });
typingUsers.value = new Map(typingUsers.value); // Триггерим реактивность
}
function clearTyping(userId) {
const user = typingUsers.value.get(userId);
if (user?.timeout) clearTimeout(user.timeout);
typingUsers.value.delete(userId);
typingUsers.value = new Map(typingUsers.value);
}
async function sendTyping() {
const now = Date.now();
if (now - lastTypingSent < THROTTLE_MS) return;
lastTypingSent = now;
await fetch('/typing.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `chat_id=${chatId}&socket_id=${pushler.socketId}`
});
}
onMounted(() => {
channel = pushler.channel(`private-chat-${chatId}`);
channel.on('user:typing', handleTyping);
channel.on('message:new', (data) => clearTyping(data.message.user_id));
});
onUnmounted(() => {
for (const user of typingUsers.value.values()) {
if (user.timeout) clearTimeout(user.timeout);
}
});
return { displayText, isTyping, sendTyping };
}
Использование Vue composable
Vue
<template>
<div class="chat">
<!-- Индикатор печати -->
<Transition name="fade">
<div v-if="typing.displayText.value" class="typing-indicator">
<span class="dots">
<span></span><span></span><span></span>
</span>
{{ typing.displayText.value }}
</div>
</Transition>
<!-- Поле ввода -->
<textarea
v-model="message"
v-on:input="typing.sendTyping"
v-on:keydown.enter.exact.prevent="sendMessage"
placeholder="Сообщение..."
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useTypingIndicator } from './composables/useTypingIndicator';
const props = defineProps(['pushler', 'chatId', 'currentUserId']);
const message = ref('');
const typing = useTypingIndicator(
props.pushler,
props.chatId,
props.currentUserId
);
</script>
<style scoped>
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #64748b;
font-size: 13px;
}
.dots {
display: flex;
gap: 3px;
}
.dots span {
width: 6px;
height: 6px;
background: #64748b;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.dots span:nth-child(1) { animation-delay: -0.32s; }
.dots span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
⚠️ Типичные ошибки
1. Слишком частые запросы
JavaScript
// ❌ Неправильно — запрос на каждое нажатие
input.addEventListener('keydown', () => {
fetch('/typing.php'); // 100+ запросов в секунду!
});
// ✅ Правильно — throttle
let lastSent = 0;
input.addEventListener('input', () => {
if (Date.now() - lastSent > 2000) {
lastSent = Date.now();
fetch('/typing.php', {
method: 'POST',
body: `chat_id=${chatId}&socket_id=${pushler.socketId}`
});
}
});
2. Индикатор не исчезает
JavaScript
// ❌ Неправильно — нет таймаута
channel.on('user:typing', (data) => {
showTypingIndicator(data.user_name);
// Индикатор висит вечно!
});
// ✅ Правильно — автоматическое скрытие
let typingTimeout;
channel.on('user:typing', (data) => {
showTypingIndicator(data.user_name);
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
hideTypingIndicator();
}, 3000);
});
3. Показываем себе свой индикатор
PHP
// ❌ Неправильно — событие придёт всем
$pushler->trigger("private-chat-{$chatId}", 'user:typing', $data);
// ✅ Правильно — исключаем отправителя через socket_id
$socketId = $_POST['socket_id'];
$pushler->trigger("private-chat-{$chatId}", 'user:typing', $data, $socketId);
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут