
Привет! Меня зовут Алексей Гомелевский, я frontend-разработчик в Garage Eight. Моя команда занимается улучшением взаимодействия пользователей с продуктом, и недавно мы решили реализовать комментарии. В этой статье расскажу, как выбирали между решением из коробки и собственной разработкой, с какими сложностями столкнулись и как на базе комментариев создали чаты.
Зачем нам потребовалось создавать комментарии
Мы в Garage Eight разрабатываем международную экосистему инвестиционных продуктов, а конкретно моя команда сфокусирована на мотивации пользователей взаимодействовать с сервисами. Мы стремимся постоянно улучшать UX, тем самым удерживая их и формируя лояльность, что в конечном счете влияет на LTV пользователя.
Возможно, вы когда-нибудь слышали про Октализ — фреймворк геймификации от Ю-Кай Чоу (Yu-kai Chou), описывающий восемь основных мотивационных драйверов. Он показывает, что важно привлекать клиентов не только напрямую, но и через психологические факторы: например, стремление к саморазвитию, самовыражению, командной работе или избеганию неприятных моментов. Для этого существуют различные инструменты. Например, колеса фортуны, ежедневные ачивки за вход или чаты.

Один из таких драйверов — социальная мотивация. Он строится на человеческой потребности в принятии, взаимодействии с комьюнити и обратной связи от других. Именно поэтому, чтобы решить задачу повышения вовлеченности пользователей, мы начали искать инструмент, который принесет клиенту ценность от взаимодействия с другими юзерами.
Самым простым решением стали комментарии в уже существующей ленте с постами. Нам нужно было проверить гипотезу, что взаимодействие между пользователями «заведётся» именно в разрабатываемом нами продукте. План был такой:
— реализовать MVP-функционал комментариев;
— запустить фичу на небольшой сегмент пользователей.
Если вовлечение будет достаточным, доработать функционал — добавить возможность удалять, репортить комментарии, цитировать других. А дальше решить: масштабировать ли социальную часть во что-то большее, чем комментарии.
Выбираем между решением из коробки и собственной разработкой
Для проверки гипотезы нужно было либо выбрать решение из коробки, либо самостоятельно написать его так, чтобы оно одинаково функционировало на всех платформах (iOS, Android и Web). У каждого варианта свои плюсы и минусы:
Решение из коробки. Подойдет, чтобы быстро и относительно недорого проверить гипотезу, для тестирования есть множество инструментов: Disqus, Commento, Getstream и другие.
В то же время у каждого из сервисов есть свои особенности API и интеграций, времени внедрения и стоимости, с которыми нужно разбираться. Еще в некоторых сервисах достаточно плохая документация: устаревшие блоки, различные описания под платформы. Кроме того, многие продукты дают функционал, который невозможно кастомизировать под наши цели и требования к скорости загрузки.
Собственная разработка. В этом случае мы смогли бы учесть все особенности продукта, конкретные цели и технические требования к скорости загрузки фичей.
При этом такой вариант был сильно дороже готового решения. Если коробочную версию можно протестировать во время бесплатной подписки, то стоимость собственной сложилась бы из зарплат сотрудников и косвенных расходов. И, конечно, самостоятельная разработка займет больше времени.
Мы оценили пул работы и поняли, что сможем самостоятельно написать модуль комментариев. При этом раскладе за несколько спринтов реализовали MVP, увидели первые результаты. Затем итерационно дорабатывали. За два квартала мы создали пять элементов для веб-версии и мобильных приложений на Android и iOS:
Список комментариев.
Строку ввода сообщения.
Возможность репортить чужие комментарии — для этого на бэк отправляется запрос с жалобой, а модератор ловит его и решает, нужно ли удалять.
Возможность удалить свой комментарий.
Блок реакций на комментарии — дали выбор из пяти эмоджи.
Когда всё было готово к запуску, мы не решились сразу раскатывать новый функционал на всех пользователей, потому что хотели поэтапно проверять, будут ли клиенты и дальше вовлекаться в диалоги и общаться без модератора. Тестовые группы собирали по различным регионам, языку приложения, особенностям взаимодействия с сервисом, а также тестировали несколько механик обучений c ментором для новичков. Постепенно стали охватывать всё большую часть пользователей, сейчас функция комментариев доступна уже всем.
Level up: добавляем чаты
Спустя время мы решили еще больше погрузиться в работу с драйвером социальной мотивации, чтобы сильнее вовлечь пользователей в продукт. На тот момент комментарии стали востребованы аудиторией, и мы приступили к разработке новых фич с фокусом на взаимодействие клиентов.
Мы хотели не только увеличивать время работы с сервисом, но и формировать сообщество и повышать лояльность. Предположили, что для этого подойдут чаты, и стали тестировать новую гипотезу.
Для быстрой проверки решили использовать чаты в Телеграме: приглашали в чаты пользователей со схожими признаками, смотрели конверсию во вступление, считали количество активных участников и вовлеченность в диалоги. Это было удобно для проверки верхнеуровневых гипотез, но глобально хотелось не уводить пользователей на внешние площадки.
Аналитика показала, что гипотеза жизнеспособна, и мы решили сделать чаты уже в продукте. Запланировали добавить фичи создания групп, где пользователи смогут общаться и ставить реакции на сообщения, а администраторы — модерировать процесс. Чтобы оценить скоуп задач, нам хватило уже полученного опыта интеграции комментариев.
В этот раз мы снова выбирали между готовым решением и собственной разработкой. В этом нам помог анализ кастомных сервисов, который мы собирали для реализации комментариев.
Решение | Описание | Преимущества и ограничения |
Muut | SaaS-платформа для комментариев и форумов. SDK для Android (Kotlin), iOS (Swift), фронтенд (React). Настройки внешнего вида и функциональности | Нет SDK под Android/iOS, только веб-виджет или API. Нет поддержки реакций на комментарии в API |
Disqus | Популярное решение для комментариев | Не обеспечивает прямую интеграцию или управление |
Commento | Открытое решение. Серверная часть комментариев. Гибкая архитектура для интеграции | Для Android/iOS нужен WebView или WKWebView. Использует JS API. Возможны доработки и проблемы при апдейтах |
CommentBox.io | Простое решение с JS-библиотекой для интеграции комментариев. Поддерживает модерацию и реакции | Для Android/iOS нужен WebView или WKWebView |
Stream | Решение для комментариев и чатов. API и SDK для интеграции комментариев в приложения | Есть поддержка Android/Flutter |
Одним из подходящих вариантов был Getstream. Мы не рассматривали его для интеграции комментариев, потому что у него на тот момент не было SDK на Kotlin. Но к началу разработки чатов он уже обновился и всё поддерживал — решили использовать его.
Добавляем чаты из Getstream
В Getstream есть всё необходимое для интеграции: каналы, инвайты в чаты, реакции, отличная модерация, возможность создавать новые обсуждения, а главное — хороший API.
На интеграцию Getstream мы потратили спринт, чаты заработали из коробки, нужно было лишь обновить UI. На этот этап мы потратили чуть больше времени из-за плохой документации. Разберем по компонентам, как всё получилось.
<ErrorBoundary fallback={( <ErrorLoading step="init_chat" onReloadClick={handleReloadClick} /> )} > <ClientSuspense fallback={(<Preloader />)}> <Chat apiKey={apiKey} userId={profile.id} avatar={profile.avatar?.url} username={profile.nickname} userToken={token} isVisible={isVisible} /> </ClientSuspense> </ErrorBoundary>
Пример интеграции компонента чата. Для инициализации достаточно прокинуть token, apiKey и данные пользователя, который будет авторизован
Из коробки Getstream можно легко установить пакет в ваш проект, добавить токен авторизации и apikey с ником пользователя — интеграция готова. С точки зрения стека мы использовали React + TS, а также Jotai как стейтменеджер.
Так выглядит компонент инициализации чата с небольшими доработками и фичами:
// Компонент инициализации чата // prop isVisible нужен для того, чтобы рендерить чат всегда, но показывать его только при открытии его в интерфейсе. // удобно для нотификаций, например export const ChatStream: FC<ChatStreamProps> = memo(({ isVisible }: ChatStreamProps) => { // обязательный параметр для инициализации const apiKey = STREAM_API_KEY || ''; // получаем данные пользователя const [profileLoadable, refreshProfile] = useAtom(profileLoadableAtom); // получаем уникальный токен от бэкенда // можно его сгенерировать и на фронте, https://getstream.io/chat/docs/react/tokens_and_authentication/, // но нам важно было вынести эту логику на бэкенд const [chatTokenLoadable, refreshSChatToken] = useAtom(chatTokenLoadableAtom); const streamChatActiveChannelId = useAtomValue(streamChatActiveChannelIdAtom); const { state: profileState, data: profile } = profileLoadable; const { state: tokenState, data: token } = chatTokenLoadable; if (profileState === 'loading' || tokenState === 'loading') { return <Preloader />; } if (profileState === 'hasError' || 'status' in profile) { return ( <ErrorLoading step="get_profile" onReloadClick={handleReloadClick} /> ); } // рисую компонент создания никнейма для пользователей, у которых его еще нет // для чата никнейм не обязательный, но тогда он его сам сгенерирует для пользователя // перед тем как будем запрашивать токен, создаю экран if (!profile.nickname) { return ( <NicknameChat onSubmit={handleReloadClick} /> ); } if (tokenState === 'hasError' || 'status' in token) { return ( <ErrorLoading step="get_token" onReloadClick={handleReloadClick} /> ); } return ( <ErrorBoundary fallback={( <ErrorLoading step="init_chat" onReloadClick={handleReloadClick} /> )} > <ClientSuspense fallback={(<Preloader />)}> <Chat apiKey={apiKey} userId={profile.id} avatar={profile.avatar?.url} username={profile.nickname} userToken={token.token} isVisible={isVisible} /> </ClientSuspense> </ErrorBoundary> ); });
А это непосредственно сам чат:
import type { Event, TranslationLanguages } from 'stream-chat'; import { StreamChat } from 'stream-chat'; import { Chat as ChatComponent} from 'stream-chat-react'; import 'stream-chat-react/dist/css/v2/index.css'; type ChatProps = { apiKey: string; userId: string; username: string; userToken: string; avatar?: string; isVisible: boolean; }; const Chat: FC<ChatProps> = ({ apiKey, userToken, userId, username, avatar, isVisible }) => { // у Getstream из коробки есть хук темы — светлая/темная и другие const [theme] = useCurrentTheme(); // помидорка-нотификация о новых сообщениях const setStreamChatNewMessagesCount = useSetAtom(streamChatNewMessagesCountAtom); const chatClient = useMemo(() => StreamChat.getInstance(apiKey), [apiKey]); const handleNewMessage = useCallback(async (event: Event) => { if (event.user?.id !== userId) { const unreadMessages = await chatClient.getUnreadCount(userId); setStreamChatNewMessagesCount(unreadMessages.total_unread_count); } }, [setStreamChatNewMessagesCount]); const handleReadMessage = useCallback(async (event: Event) => { if (event.user?.id === userId) { const unreadMessages = await chatClient.getUnreadCount(userId); setStreamChatNewMessagesCount(unreadMessages.total_unread_count); } }, [setStreamChatNewMessagesCount, userId]); useEffect(() => { const connectUser = async () => { await chatClient.connectUser( { id: userId, name: username, image: avatar }, userToken ); const unreadMessages = await chatClient.getUnreadCount(userId); setStreamChatNewMessagesCount(unreadMessages.total_unread_count); }; connectUser(); chatClient.on('message.new', handleNewMessage); chatClient.on('message.read', handleReadMessage); return () => { chatClient.off('message.new', handleNewMessage); chatClient.off('message.read', handleReadMessage); }; }, [[chatClient,userId,username,avatar,userToken,handleNewMessage,handleReadMessage,]]); if (!isVisible) { return null; } if (!chatClient) { return <Preloader />; } return ( <main className={styles.chat}> <ChatComponent theme={theme.current === 'dark' ? 'str-chat__theme-dark' : 'str-chat__theme-light'} client={chatClient} > // компонент Chats (список чатов) кастомный <Chats userId={userId} /> </ChatComponent> </main> ); }; export default Chat;
И компонент Chats: список чатов и сама страница активного чата со списком переписок и интерфейсом ввода сообщений.
export const Chats: FC<ChatsProps> = ({ userId }) => { // использую контекст для получения всех данных по чату const { client, channel, setActiveChannel } = useChatContext(); const [chats, setChats] = useState<StreamChannel[] | null>(null); const [isChatsLoading, setIsChatsLoading] = useState(true); const [isChatsError, setIsChatsError] = useState(false); const setNotificationStatus = useSetAtom(notificationStatusAtom); const [streamChatActiveChannelId, setStreamChatActiveChannelId] = useAtom(streamChatActiveChannelIdAtom); const handleBackToChatList = useCallback(() => { const currentUrl = new URL(window.location.href); setActiveChannel(); currentUrl.searchParams.set(CHAT_TAB, ''); setStreamChatActiveChannelId(null); window.history.pushState(null, '', currentUrl.toString()); }, [setStreamChatActiveChannelId]); const getChannels = useCallback(async () => { const filterChannel = { type: 'messaging', members: { $in: [userId]}}; const sortChannel: ChannelSort[] = [{ last_message_at: -1 }]; setIsChatsError(false); try { const channelList = await client?.queryChannels(filterChannel, sortChannel, { limit: 30 }); setChats(channelList); if (streamChatActiveChannelId) { const channelFromUrl = channelList.find( (channelItem) => channelItem.id === streamChatActiveChannelId ); if (!channelFromUrl) { handleBackToChatList(); return; } setActiveChannel(channelFromUrl); } } catch { setIsChatsError(true); } finally { setIsChatsLoading(false); } }, [client,userId,streamChatActiveChannelId,handleBackToChatList]); useEffect(() => { getChannels(); }, [streamChatActiveChannelId]); if (isChatsLoading) { return <Preloader />; } if (!chats?.length || isChatsError) { return <ErrorAccess />; } // что происходит дальше: // при успешном рендере показываем компонент списка чатов // компонент Channel, Window, ChannelHeader, MessageList, MessageInput и Thread — из коробки Getstream, их не меняли // они являются core чата, документация: https://getstream.io/chat/react-chat/tutorial/#initial-core-component-setup return ( <> {!channel && ( <List userId={userId} /> )} <Channel channel={channel} EmojiPicker={EmojiPicker} > <Window> {!!channel && ( <BackToChatsButton onCLick={handleBackToChatList} /> )} <ChannelHeader /> <MessageList /> <MessageInput maxRows={5} minRows={1} grow={true} /> </Window> <Thread autoFocus={true} /> </Channel> </> ); };
Так мы получили полноценный модуль чатов и раскатили его на пользователей. Сейчас они используют функцию для получения бонусов, обучения и информации. Над продуктом дальше работают команды маркетинга, которые могут эффективно использовать чаты для взаимодействия с аудиторией.
Если подводить итог кастомной интеграции коробочного решения, то могу сказать, что получилось хорошо. В нашем случае единственная сложность была с дополнительной кастомизацией оформления чатов, внутренние компоненты со списком чатов мы не меняли.
В заключение
При работе над новыми проектами важно понимать, какие цели есть у бизнеса и какой MVP может их закрыть. Может быть ситуация, когда вы целый квартал внедряете готовое решение, хотя бизнесу было бы достаточно обычной формы ввода сообщения и списка комментариев.
В нашем случае для реализации комментариев потребовалась собственная разработка, а чаты мы смогли внедрить из коробки. Если вы планируете внедрять в свой продукт подобные функции — рассмотрите коробочные решения. Они постоянно обновляются, поэтому последние версии могут подойти для реализации. Если же решите писать модули самостоятельно, обязательно заранее протестируйте гипотезы, подтвердите их жизнеспособность и спланируйте разработку так, чтобы она могла быстро внедриться в готовый продукт и начать приносить пользу бизнесу.
Закончить свою публикацию хочу фразой: Don’t build a game. Build a system where people want to keep playing, что переводится как «Не создавайте игру. Создавайте систему, в которую людям захочется возвращаться».
