Web PUSH Notifications быстро и просто

Добрый день. В этой небольшой заметке я хочу рассказать как быстро и просто настроить push-уведомления на вашем сайте. Эта статья ни в коем случае не претендует на звание исчерпывающего руководства, но, я надеюсь, что она даст точку старта для дальнейшего изучения.


Информации по этой теме в интернете полно, но она фрагментирована, разбросана по разным ресурсам и перемешена с уведомлениями для мобильных устройств с примерами на Java, C++ и Python. Нас же, как веб-разработчиков, интересует JavaScript. В этой статье я постараюсь саккумулировать всю необходимую и полезную информацию.


Web PUSH Notifications


Я думаю, вы уже знаете что такое push-уведомления, но я всё же напишу коротко о главном.


Пользователь, заходя на сайт, вытягивает (pull) с него данные. Это удобно и безопасно, но с развитием интернет ресурсов, появилась необходимость оперативно доставлять информацию пользователям не дожидаясь пока те сами сделают запрос. Так и появилась технология принудительной доставки (push) данных с сервера клиенту.



Важно

Push-уведомления работают только если у вас на сайте есть HTTPS.
Без валидного SSL сертификата запустить не получится. Так что если у вас еще нет поддержки HTTPS, то пришло время её сделать. Рекомендую воспользоваться Let’s Encrypt.
Для запуска на localhost нужно прибегать к хитростям. Я же тестировал скрипты на Github Pages.


Оглавление
  • Хорошие уведомления
  • Теория
    • Что же происходит на стороне клиента?
    • Сложности на серверной стороне
  • Практика
    • Приступаем к написанию клиента
    • Отправка уведомлений с сервера
  • TTL и дополнительный контроль над уведомлением
  • Заключение
  • Поиграться
    • Проект на GitHub Pages
    • Проект на моей площадке
  • Ссылки

Хорошие уведомления


Сразу хочу оговориться, что push-уведомления не для рекламных рассылок. Отправлять нужно только то, что действительно нужно конкретному пользователю и на что он действительно должен оперативно отреагировать.


Хороший пример:


  • Отправка уведомления об изменении статуса обращения пользователя в службу техподдержки;
  • Отправка уведомления об изменении статуса заказа;
  • Появление на складе товара, который ждал пользователь;
  • Ответили на комментарий пользователя к статье;
  • Новая задача в багтрекере со статусом Bug или Critical.

Плохой пример:


  • Новые поступления на склад;
  • Скидки и акции на товары;
  • Новая статья на сайте;
  • Ответили на комментарий пользователя к статье, который он написал год назад.

Плохие примеры тоже требуют уведомления, но на них не нужно реагировать оперативно. Эти уведомления можно отправить на почту. Вообще, все важные уведомления рекомендуется дублировать на почту, так-как push-уведомления могут не дойти до пользователя по разным, не зависящих от вас, причинам. Также важным фактором является актуальность события. Об этом я еще поговорю чуть позже. Рекомендую к прочтению:


  • Хорошие уведомления по мнению Google
  • Рекламная статья от PushAll

Вернемся к нашим баранам. Так как же всё это работает? Для начала немного теории.


Теория


Среди непосвященных бытует мнение что push-уведомления это простая технология, не требующая для реализации особых ресурсов. В действительности же это целый пул технологий.


Для начала небольшая схема того как все это работает (анимированная схема):


Схема взаимодействия в PUSH Notifications


  1. Сервер отдает страницу пользователю;
  2. Клиент подключается к серверу сообщений, регистрируется и получает ID;
  3. Клиент отправляет полученный ID на сервер и сервер привязывает конкретного пользователя к конкретному устройству используя ID устройства;
  4. Сервер отправляет сообщение клиенту через сервер сообщений используя полученный ранее ID.

К сожалению, мне не удалось выяснить кто и как создает ID устройства и как сервер сообщений привязывается к конкретному устройству. Я использовал сервер сообщений Firebase Cloud Messaging от Google и его библиотеку. К сожалению, я не смог выяснить можно ли его заменить на свой сервер и как это сделать.


Забавный факт

Изначально для отправки сообщений использовали:
Cloud to Device Messaging

Потом его заменили на:
Google Cloud Messaging

А потом еще раз поменяли на:
Firebase Cloud Messaging

Интересно, что дальше.


Что же происходит на стороне клиента?


  • JavaScript запрашивает у пользователя разрешение на показ уведомлений;
  • Если пользователь одобрил, то подключаемся к серверу сообщений и получаем ID устройства;
  • Отправляем идентификатор на наш сервер, чтобы мы идентифицировали пользователя;
  • Инициализируем JavaScript воркер который будет работать в фоне, получать сообщения от сервера сообщений и показывать их пользователю;
  • Подключаемся к серверу сообщений и ждем новых поступлений.

Запрос прав на показ уведомлений


Заметка

Google рекомендует использовать переключатель для подписки и отписки от уведомлений. Таким образом, инициация процедуры подписки на уведомления исходит от пользователя, а не от сайта. Принудительно подписывать на уведомления каждого приходящего пользователя, это плохая практика.

Это все выглядит очень сложно, но на сервере все не проще.


Сложности на серверной стороне


  • Понятно, что идентификатор устройства, присылаемый пользователем, мы сохраняем в базу данных;
  • Идентификатор устройства хорошо бы привязывать к пользователю, чтобы отправлять персонализированные сообщения;
  • Стоит помнить, что пользователь у нас один, а устройств у него может быть несколько, также одним устройством могут пользоваться несколько пользователей;
  • Отправка уведомлений пользователям не самая дешевая операция и поэтому событие, инициирующее отправку уведомления, нужно ставить в очередь на отправку;
  • Только маленькие проекты с малым числом получателей могут позволить себе отправлять уведомления по событию, в течении того-же HTTP запроса;
  • Так у нас появляется система очередей на RabbitMQ, Redis и т.д.;
  • Появляются демоны/воркеры которые разбирают очередь и другие инструменты поддержки очередей;
  • Для увеличения скорости отправки можно распараллелить процесс и разнести его на несколько нод.

Практика


Наконец-то, мы перешли к самому главному. Как я уже говорил ранее, в качестве сервера сообщений мы будем использовать Firebase Cloud Messaging, поэтому мы начинаем с регистрации и создания проекта на Firebase.


Тут все просто:


  • Заходим на сайт;
  • Регистрируемся;
  • Жмём кнопку Create new project или Import Google project, если у вас уже есть проект;
  • При создании указываем название проекта и страну;
  • После создания проекта попадаем на его dashboard;
  • В меню наводим на колесико рядом с Overview и выбираем Project settings;
  • На открывшейся странице переходим во вкладку Cloud Messaging;
  • Нас интересует Server key, который будет использоваться для отправки сообщений с сервера и Sender ID который будет использоваться для получения сообщений на стороне клиента.

Можно еще покопаться в настройках и поиграться с разделением прав доступа, но, в общем-то, работа с сайтом Firebase закончена.


Приступаем к написанию клиента


Начнем с того что создадим Service Worker для получения push-уведомлений.
Создаем файл firebase-messaging-sw.js с следующим содержимым.


// firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/3.6.8/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.6.8/firebase-messaging.js');

firebase.initializeApp({
    messagingSenderId: ''
});

const messaging = firebase.messaging();

где,


  •  — это Sender ID который мы получили после регистрации в Firebase.

Важное замечание

Файл Service Worker-а должен называться именно firebase-messaging-sw.js и обязательно должен находиться в корне проекта, то есть доступен по адресу https://example.com/firebase-messaging-sw.js. Путь к этому файлу жестко прописан в библиотеке Firebase.

Написанного кода достаточно для того чтобы показывать уведомления. О дополнительных возможностях поговорим чуть позже. Теперь добавим библиотеку Firebase и скрипт подписки в наш шаблон страницы.




Добавляем на страницу кнопку для подписки на уведомления



Подписка на уведомления


// firebase_subscribe.js
firebase.initializeApp({
    messagingSenderId: ''
});

// браузер поддерживает уведомления
// вообще, эту проверку должна делать библиотека Firebase, но она этого не делает
if ('Notification' in window) {
    var messaging = firebase.messaging();

    // пользователь уже разрешил получение уведомлений
    // подписываем на уведомления если ещё не подписали
    if (Notification.permission === 'granted') {
        subscribe();
    }

    // по клику, запрашиваем у пользователя разрешение на уведомления
    // и подписываем его
    $('#subscribe').on('click', function () {
        subscribe();
    });
}

function subscribe() {
    // запрашиваем разрешение на получение уведомлений
    messaging.requestPermission()
        .then(function () {
            // получаем ID устройства
            messaging.getToken()
                .then(function (currentToken) {
                    console.log(currentToken);

                    if (currentToken) {
                        sendTokenToServer(currentToken);
                    } else {
                        console.warn('Не удалось получить токен.');
                        setTokenSentToServer(false);
                    }
                })
                .catch(function (err) {
                    console.warn('При получении токена произошла ошибка.', err);
                    setTokenSentToServer(false);
                });
    })
    .catch(function (err) {
        console.warn('Не удалось получить разрешение на показ уведомлений.', err);
    });
}

// отправка ID на сервер
function sendTokenToServer(currentToken) {
    if (!isTokenSentToServer(currentToken)) {
        console.log('Отправка токена на сервер...');

        var url = ''; // адрес скрипта на сервере который сохраняет ID устройства
        $.post(url, {
            token: currentToken
        });

        setTokenSentToServer(currentToken);
    } else {
        console.log('Токен уже отправлен на сервер.');
    }
}

// используем localStorage для отметки того,
// что пользователь уже подписался на уведомления
function isTokenSentToServer(currentToken) {
    return window.localStorage.getItem('sentFirebaseMessagingToken') == currentToken;
}

function setTokenSentToServer(currentToken) {
    window.localStorage.setItem(
        'sentFirebaseMessagingToken',
        currentToken ? currentToken : ''
    );
}

Вот и все. Это весь код который требуется для получения push-уведомлений.


Отправка уведомлений с сервера


В общем виде отправка уведомления выглядит так:


POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=YOUR-SERVER-KEY
Content-Type: application/json

{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=40&height=40",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}

где,


  • YOUR-SERVER-KEY — это Server key который мы получили при регистрации в Firebase;
  • YOUR-TOKEN-ID — это ID устройства конкретного пользователя.

Все поля по порядку:


  • notification — параметры уведомления;
  • title — заголовок уведомления. Лимит 30 символов;
  • body — текст уведомление. Лимит 120 символов;
  • icon — иконка уведомления. Есть некоторые стандарты размеров иконок, но я использую 192×192. Иконки меньшего размера плохо смотрятся на мобильных устройствах;
  • click_action — URL адрес страницы на которую перейдет пользователь кликнув по уведомлению;
  • to — ID устройства получателя уведомления;
  • Полный список параметров здесь.

Уведомление

Это пример отправки одного уведомления одному получателю. Можно отправить одно уведомление сразу нескольким получателям. Вплоть до 1000 получателей за раз.


{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "registration_ids": [
    "YOUR-TOKEN-ID-1",
    "YOUR-TOKEN-ID-2"
    "YOUR-TOKEN-ID-3"
  ]
}

Пример ответов от сервера сообщений:


Отправка уведомления в Chrome
{
    "multicast_id": 6407277574671070000,
    "success": 1,
    "failure": 0,
    "canonical_ids": 0,
    "results": [
        {
            "message_id": "0:1489072146895227%e609af1cf9fd7ecd"
        }
    ]
}

Отправка уведомления в FireFox
{
    "multicast_id": 7867877497742898000,
    "success": 1,
    "failure": 0,
    "canonical_ids": 0,
    "results": [
        {
            "message_id": "https://updates.push.services.mozilla.com/m/gAAAAABYwWmlTCKje5OLwedhNUQr9LbOCmZ0evAF9HJBnR-v7DF2KEkZY3zsT8AbrqB6JfJO6Z6vsotLJMmiIvJs9Pt1Q9oc980BRX2IU1-jlzRLIhSVVBLo2i80kBvTMYadVAMIlSIyFkWm-qg_DfLbenlO9z1S4TGMJl0XbN5gKMUlfaIjnX2FBG4XsQjDKasiw8-1L38v"
        }
    ]
}

Ошибка отправки уведомления
{
    "multicast_id": 8165639692561075000,
    "success": 0,
    "failure": 1,
    "canonical_ids": 0,
    "results": [
        {
            "error": "InvalidRegistration"
        }
    ]
}

Полный список кодов ошибок.


Мы не привязаны к какому-то конкретному языку программирования и для простоты примера будем использовать PHP с расширением cURL. Скрипт отправки уведомления нужно запускать из консоли.


#!/usr/bin/env php
 $YOUR_TOKEN_ID,
    'notification' => [
        'title' => 'Ералаш',
        'body' => sprintf('Начало в %s.', date('H:i')),
        'icon' => 'https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192',
        'click_action' => 'http://eralash.ru/',
    ],
];
$fields = json_encode($request_body);

$request_headers = [
    'Content-Type: application/json',
    'Authorization: key=' . $YOUR_API_KEY,
];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, $request_headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);

echo $response;

TTL и дополнительный контроль над уведомлением


Важным свойством для уведомления может является время его актуальности. Это зависит от ваших бизнес процессов. По умолчанию время жизни уведомлений 4 недели. Это очень много для уведомлений такого характера. Например, уведомление «Ваша любимая передача начинается через 15 минут» актуально в течении 15 минут. После этого сообщение уже не актуально и показываться не должно. За контроль над временем жизни отвечает свойство time_to_live со значением от 0 до 2419200 секунд. Подробней читать в документации. Сообщение с указанным TTL будет иметь вид:


{
  "notification": {
    "title": "Ералаш",
    "body": "Начало через 15 минут",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "time_to_live": 900,
  "to": "YOUR-TOKEN-ID"
}

Сообщение вида «Ваша любимая передача начинается через 15 минут» актуально в течении 15 минут, но уже через минуту после отправки оно станет не корректным. Потому что передача начнется не через 15 минут, а уже через 14. Контролировать такие ситуации нужно на стороне клиента.


Для этого мы поменяем отправляемое с сервера сообщение:


{
  "data": {
    "title": "Ералаш",
    "time": 1489006800,
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "time_to_live": 900,
  "to": "YOUR-TOKEN-ID"
}

Обратите внимание что поле notification поменялось на data. Теперь не будет вызываться обработчик по умолчанию Firebase и нам нужно самостоятельно сделать это. Добавим в конце файла firebase-messaging-sw.js следующие строки:


// регистрируем свой обработчик уведомлений
messaging.setBackgroundMessageHandler(function(payload) {
    if (typeof payload.data.time != 'undefined') {
        var time = new Date(payload.data.time * 1000);
        var now = new Date();

        if (time < now) { // истек срок годности уведомления
            return null;
        }

        var diff = Math.round((time.getTime() - now.getTime()) / 1000);

        // показываем реальное время в уведомлении
        // будет сгенерировано сообщение вида: "Начало через 14 минут, в 21:00"
        payload.data.body = 'Начало через ' +
            Math.round(diff / 60) + ' минут, в ' + time.getHours() + ':' +
            (time.getMinutes() > 9 ? time.getMinutes() : '0' + time.getMinutes())
        ;
    }

    // Сохраяем data для получения пареметров в обработчике клика
    payload.data.data = payload.data;

    // Показываем уведомление
    return self.registration.showNotification(payload.data.title, payload.data);
});

// свой обработчик клика по уведомлению
self.addEventListener('notificationclick', function(event) {
    // извлекаем адрес перехода из параметров уведомления 
    var target = event.notification.data.click_action || '/';
    event.notification.close();

    // этот код должен проверять список открытых вкладок и переключатся на открытую
    // вкладку с ссылкой если такая есть, иначе открывает новую вкладку
    event.waitUntil(clients.matchAll({
        type: 'window'
    }).then(function(clientList) {
        // clientList почему-то всегда пуст!?
        for (var i = 0; i < clientList.length; i++) {
            var client = clientList[i];
            if (client.url == target && 'focus' in client) {
                return client.focus();
            }
        }

        // Открываем новое окно
        if (clients.openWindow) {
            return clients.openWindow(target);
        }
    }));
});

Вот таким незамысловатым образом мы получили полный контроль над уведомлением. Что самое интересное, пользователю мы показываем время уведомления в его часовом поясе. Это актуально для сервисов который работают по всему миру или регионах с широким разбросом часовых поясов как у матушки-России.


Заключение


А теперь поговорим о грустном. Не смотря на все прелести технологии, у неё есть ряд недостатков:


  1. Полноценная поддержка только в Chrome последних версий. Firefox, Opera, IE, Safari и прочая братия остаются за бортом. Но зато работает в Chrome на мобильных устройствах.
  2. Не смотря на заявленную поддержку Firefox, в нем уведомления не работают. Точнее работают, но только 1 раз. Только первое отправленное уведомление доходит до клиента, а все последующие пропадают в небытие. Буду признателен если кто-то поделится своими результатами тестирования в браузерах.
  3. Открытый сайт и работающий Service Worker не гарантируют доставку сообщения. Хотя уведомления могут дойти и при закрытом браузере.
  4. Нельзя отправить сообщение с клиента. То есть отправить запрос с помощью AJAX или веб-формы на сервер, чтобы тот отправил push-уведомление нам на клиентскую сторону. Не работает. Запрос отправляется, сообщению присваивается ID, но до нас сообщение не доходит. Можете поиграться.

Библиотека Firebase скрывает в себе много тайн и её исследование могло бы дать ответы на некоторые вопросы, но это уже выходит за рамки этой статьи.


Поиграться


Проект на GitHub Pages


Так как для запуска Service Worker-а нужен HTTPS, то самым простым решением было разместить проект на GitHub Pages, что я и сделал.


Проект доступен по адресу: https://github.com/peter-gribanov/serviceworker
Исходники проекта: https://peter-gribanov.github.io/serviceworker/


Проект представляет из себя только клиентскау часть. Для того что бы получить уведомление надо:


  • Зайти на страницу;
  • Нажать кнопку Subscribe;
  • Браузер запросит разрешение на показ уведомлений;
  • Подтверждаем разрешение;
  • На странице и в консоли браузера будет напечатан ID вашего устройства.

Далее, в любом инструменте отправки HTTP запросов отправляем, уже описанный ранее, запрос. Можно использовать сURL, я предпочитаю приложение Postman для Chrome.


POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=AAAAaGQ_q2M:APA91bGCEOduj8HM6gP24w2LEnesqM2zkL_qx2PJUSBjjeGSdJhCrDoJf_WbT7wpQZrynHlESAoZ1VHX9Nro6W_tqpJ3Aw-A292SVe_4Ho7tJQCQxSezDCoJsnqXjoaouMYIwr34vZTs
Content-Type: application/json

{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}

где,


  • YOUR-TOKEN-ID — это ID устройства который вы получили на странице приложения.

Вот и все. Получаем уведомление и радуемся жизни.


Проект на моей площадке


Для статьи, я хотел сделать страницу на которой была бы кнопка, нажав на которую получаешь push-уведомление, но из этого ничего не вышло. Ни через AJAX, ни через веб-формы уведомления не доходят. Возможно оно и к лучшему.


Можете убедится в этом сами.


Проект доступен по адресу: https://push.peter-gribanov.ru/
Исходники проекта: https://github.com/peter-gribanov/article-push
Релизы проекта: https://github.com/peter-gribanov/article-push/releases


Пример сообщения для отправки через HTTP клиенты:


POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=AAAAaGQ_q2M:APA91bGCEOduj8HM6gP24w2LEnesqM2zkL_qx2PJUSBjjeGSdJhCrDoJf_WbT7wpQZrynHlESAoZ1VHX9Nro6W_tqpJ3Aw-A292SVe_4Ho7tJQCQxSezDCoJsnqXjoaouMYIwr34vZTs
Content-Type: application/json

{
  "data": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}

где,


  • YOUR-TOKEN-ID — это ID устройства который вы получите после подтверждения. ID устройства будет отличатся от того что на GitHub Pages и показывается он только в консоли браузера.

Обратите внимание, на GitHub Pages используется свойство notification, а на моей площадке data.


Ссылки


  • Про PUSH
    • Wikipedia о технологии PUSH
    • Хорошие уведомления по мнению Google
    • Рекламная статья от PushAll
  • SSL
    • HTTPS на localhost
    • Тестирование SSL сертификата
    • Let’s Encrypt
    • Статья по настройке Let’s Encrypt
  • UI
    • Подписка на уведомления
    • Стратегия определения состояния для фложка подписки
  • JavaScript API
    • Notification
    • ServiceWorker
    • Использование Service Worker
    • localStorage
    • Promise
  • Отправка уведомлений
    • Руководство
    • Проект из документации
    • Параметры сообщения
    • Список кодов ошибок
    • TTL
    • Размер иконок
    • Приложение Postman для Chrome
  • Поиграться
    • На GitHub Pages
      • Проект
      • Исходники
    • На моей площадке
      • Проект
      • Исходники
      • Релизы

Комментарии (3)

  • 13 марта 2017 в 08:32

    0

    Прямо стало интересно, почему не доходят. Посмотрел весь код, наскоком ничего не обнаружил, надо повторять всё разбираться.

    У меня сомнения что всё это не работает, потому что в случае с OneSignal это как-то отрабатывает. Банально ставишь плагин уведомлений к Wordpress, жмёшь «Send test notification» и оно приходит.

    Как гипотеза, js api от firebase выдает не тот токен (он может и клиентский, да не тот).

    • 13 марта 2017 в 08:56

      0

      У Firefox вообще свой пуш- сервис. Firebase проксирует через себя запрос и бага где то на стороне этого проксирования. Мы шлем напрямую в Mozilla Push Service и все приходит. При этом не используем payload, а запрашиваем уведомления с сервера, что позволяет еще в дальнейшем показывать лишь те, что пользователь не видел на других платформах.
  • 13 марта 2017 в 09:15

    0

    ужас как бесят эти пуш-уведомления. ощущение, что их специально кто-то проталкивает. в гугл хроме при включенной галочке на «отказаться от пуш-уведомлений», пуш сообщения отдельных гениальных сайтов красавцев все равно проходят (вести ру к примеру). на других сайтах приходится при каждом заходе на страничку отказываться, отказываться и отказываться от подписок на сайт. ff пока держит защиту с dom.push.enabled false
    вы не думайте, я не против прогресса., но ситуация как с рекламой — когда ею начали злоупотреблять, то нашлись всеми рекламодателями нелюбимые контрмеры. я бы даже как-нибудь сел, да и запилил пост-фидбек по всему этому новому лицу интернета, что раньше было удобнее, а что сейчас.

© Habrahabr.ru