Привет! Меня зовут Алексей Гомелевский, я frontend-разработчик в Garage Eight. Моя команда занимается улучшением взаимодействия пользователей с продуктом, и недавно мы решили реализовать комментарии. В этой статье расскажу, как выбирали между решением из коробки и собственной разработкой, с какими сложностями столкнулись и как на базе комментариев создали чаты. 

Зачем нам потребовалось создавать комментарии

Мы в Garage Eight разрабатываем международную экосистему инвестиционных продуктов, а конкретно моя команда сфокусирована на мотивации пользователей взаимодействовать с сервисами. Мы стремимся постоянно улучшать UX, тем самым удерживая их и формируя лояльность, что в конечном счете влияет на LTV пользователя. 

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

8 мотивационных драйверов в соответствии с фреймворком Октализ
8 мотивационных драйверов в соответствии с фреймворком Октализ

Один из таких драйверов — социальная мотивация. Он строится на человеческой потребности в принятии, взаимодействии с комьюнити и обратной связи от других. Именно поэтому, чтобы решить задачу повышения вовлеченности пользователей, мы начали искать инструмент, который принесет клиенту ценность от взаимодействия с другими юзерами.

Самым простым решением стали комментарии в уже существующей ленте с постами. Нам нужно было проверить гипотезу, что взаимодействие между пользователями «заведётся» именно в разрабатываемом нами продукте. План был такой:

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

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