Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Основы
✍️

Индикатор "печатает..."

6 мин чтения Просто

Показывайте пользователю, что собеседник набирает сообщение. Throttling и таймауты.

JavaScript Vue React

📋 Что получится

Универсально: Примеры бэкенда на 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);

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут