
Привет! Меня зовут Алексей Гомелевский, я 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, что переводится как «Не создавайте игру. Создавайте систему, в которую людям захочется возвращаться».