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

Сегодня расскажем о том, как в мобильной версии Squadus реализовали востребованную функцию — «прыжок к сообщению» в чате (jump to message).

Для чего современным чатам нужна эта возможность? Прыжок позволяет «отмотать» чат от цитируемого сообщения к оригиналу. Открыть чат, который игнорировался пару дней, не с последнего сообщения, а с момента прошлого открытия. Или отыскать в истории нужное сообщение двухгодичной давности, которое во время жаркой дискуссии можно привести собеседникам как сильный аргумент. Наконец, благодаря функции пользователь может оказаться в нужном чате и на нужном месте в истории сообщений, просто кликнув push-уведомление.

О технических аспектах реализации «прыжка к сообщению» читайте под катом.


Привет, Хабр! Я Павел Майер, в МойОфис занимаюсь разработкой мобильного клиента Squadus. Ранее мои коллеги рассказали о создании бэкенда, фронтенда и UX-составляющей продукта в вебе и на десктопе, в том числе о решении проблем с реализацией чатов. Я же сконцентрируюсь на мобильной версии: опишу механику работы «прыжка к сообщению» и принципы воплощения этой функциональности в React Native, для iOS и Android.

В чем проблема?

На паре примеров из финального продукта покажу, к чему мы стремились прийти при разработке функции.

Прыжок к цитируемому сообщению:

Прыжок вниз чата:

Казалось бы, реализовать такую механику совсем несложно. Есть список сообщений, мы вызываем метод подскролла к конкретному элементу списка по его индексу — готово. Мы используем React Native, поэтому в идеальном мире всё, что требовалось бы сделать, это:

const listRef = useRef();
const scrollToMessage = (index) => this.listRef.current?.scrollToIndex({index});
return <FlatList ref={listRef} data={allMessagesInChat} />

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

Хорошо. Тогда мы можем при входе в чат отображать первую пачку из «N» сообщений, а затем при прыжке загружать историю до нужного. Но что, если до нужного сообщения — полгода интенсивной переписки с тысячей других сообщений? В таком случае прыжок будет длительным, и это тоже неприемлемо.

Можем попробовать отображать после входа первую пачку из «N» сообщений, а при прыжке загружать нужную часть истории — целевое сообщение и по «K» сообщений сверху и снизу. Звучит уже лучше, но куда помещать новый набор сообщений? Над имеющимися? Тогда при скролле вниз будет теряться часть истории... Потребуется как-то обозначить пользователю, что есть сообщения между двумя частями переписки.

Может, выкинуть всё, что было до прыжка, и показать только часть сообщений с целевым сообщением посередине? Тогда при скролле вниз/вверх нам потребуется загружать следующие сообщения с сервера. А если они уже есть в локальной базе, зачем делать двойную работу?

Много нюансов, поэтому нужно придумать код посложнее, чем в примере выше.

Что мы сделали

Принцип действия «прыжка к сообщению» проще всего описать на реальном пользовательском сценарии.

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

Пошагово процесс внутри приложения выглядит так:

Выглядит несложно. Надо лишь вежливо попросить серверную команду реализовать «ручку» api/loadSurroundingMessages, которую мы будем активировать, передавая id целевого сообщения, а в ответ получать нужный набор сообщений:

С самим прыжком разобрались. Но что же делать после прыжка, когда пользователь начнёт скроллить за пределы загруженной области?

Важно понимать, что компонент списка может обращаться к двум источникам — серверу и локальной базе. Как правило, обращение ко второму источнику — менее ресурсоёмкая операция. Но как понять, когда искать нужные сообщения, а когда идти на сервер? Ведь может оказаться, что в локальной базе история представлена с пробелами.

Допустим, пользователь совершил прыжок и загрузил одну часть истории, затем ещё один прыжок — и новая часть истории. Тогда в локальной базе переписка будет храниться обрывками, а значит, ходить в неё после такой серии прыжков нельзя.

Дополним наш пользовательский сценарий ещё одним шагом — «скроллом вниз». Теперь пользователь выполняет такую последовательность действий: открытие чата, нажатие на цитату (тут происходит прыжок), ручной скролл вниз после прыжка.

После прыжка нам необходимо разметить сообщения дополнительными полями. В случае со скроллом вниз добавим поле вроде isLastMessageInBatch. Теперь, после прыжка, самое нижнее (позднее) сообщение мы пометим таким полем со значением true. И при скролле вниз будем обращаться за сообщениями к серверу, переставляя наш флаг isLastMessageInBatch на последнее (нижнее) сообщение. До тех пор, пока не получим часть истории, в которой будет сообщение с ID, уже существующее в локальной базе; тогда флаг мы удаляем (выставляем в false). При последующем скролле вниз смело достаём сообщения из локальной базы. Но стоит учитывать, что после серии прыжков, сообщений с таким полем, выставленным в true, может быть несколько! Поэтому нужно разработать алгоритм, когда при встрече такого помеченного сообщения мы вновь переключаемся с запросов к локальной базе на запросы к серверу. Алгоритм работы мессенджера при скролле после прыжка будет выглядеть так:

Чтобы проверить, выключаем ли мы isLastMessageInBatch в false, следует взять самое нижнее сообщение из полученных и посмотреть, есть ли оно в локальной базе. Для подгрузки сообщений при скролле наверх можно реализовать такой же механизм.

Нюансы реализации и React Native

Теперь совсем немного о деталях реализации с использованием технологии React Native.

1. Добавление элементов в начало FlatList

Первое, с чем мы столкнулись при скролле вниз после прыжка, это «дёргание» контента. Чем оно вызвано? Дело в том, что контент FlatList'а смещается относительно вьюпорта при добавлении элементов в начало списка. Также важно упомянуть, что для отображения сообщений в чате мы используем FlatList с проперти inverted, выставленным в true — то есть наш список отображает элементы снизу вверх: от самого нового сообщения к наиболее давнему.

Учитывая это, при скролле вниз новые сообщения будут добавляться не сверху списка (в конец перевернутого списка), а вниз — в его начало. В таком случае видимые элементы начнут смещаться, как бы автоматически скроллиться рывками.

Эту проблему можно победить, использовав свойство FlatList maintainVisibleContentPosition. Начиная с версии 0.72, React Native поддерживает две платформы из коробки — iOS и Android. Для более ранних версий React Native можно воспользоваться библиотекой flat-list-mvcp, в которой добавлен нативный код для Android.

const listRef = useRef();
const scrollToMessage = (index) => this.listRef.current?.scrollToIndex({index});
const onViewableItemsChanged = ({viewableItems, changed}) => {
  const isTargetMessageInViewport = ...;
  if (!isTargetMessageInViewport) {
    scrollToMessage(targetMessageIndex);
    return;
  }
};
return <FlatList
  ref={listRef}
  data={allMessagesInChat}
  onViewableItemsChanged={onViewableItemsChanged}
/>

2. Лоадеры по краям списка

Определенно возникнет вопрос визуального отображения загрузки сообщений «по краям». Когда показывать лоадеры? Когда скрывать? Мы решили показывать их всегда, а скрывать так:

  • нижний — при наличии самого позднего сообщения в списке;

  • верхний — при наличии самого раннего сообщения в списке.

Да, чисто логически это некорректно — идентификатор загрузки есть, но никакого процесса на самом деле не выполняется. Тут важно сделать такую имплементацию, чтобы код подгрузки гарантировано начинал своё выполнение, когда идентификаторы загрузки видны пользователю.

Желаемое состояние — сообщения подгружаются раньше, чем пользователь доскроллит до идентификаторов загрузки.

3. Баунс в списке на Android

Допустим, вышеупомянутый метод loadSurroundingMessages вернёт «M» сообщений, где «M» — небольшое число, а сумма высот всех «М» сообщений будет меньше высоты вьюпорта. В таком случае на iOS можно будет протянуть список в нужном направлении, тем самым вызвать подгрузку и получить новые сообщения. Но с Android так не получится. Список на Android не имеет баунса, поэтому вполне может получиться, что пользователь после такого прыжка застрянет с двумя лоадерами по краям и не сможет подгрузить сообщения ни вверх, ни вниз.

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

Для решения этого потенциального бага можно отследить наличие лоадеров на экране (или крайних сообщений из загруженной пачки) с помощью onViewableItemsChanged, и если они попадают во вьюпорт, то сразу же вызвать подгрузку следующей части истории сообщений. Или, для простоты, поиграть с количеством подгружаемых сообщений — переменной «М»; возможно, этого будет достаточно.

Но! Хотя шансы попасть в описанную ситуацию малы, они возрастают, если пользователь попытается прыгнуть к «краю» чата, то есть к «i» — сообщению в конце или начале чата, где «i» < «K» , а «K» — количество запрашиваемых сообщений «над» и «под» целевым сообщением. В зависимости от серверной реализации метода loadSurroundingMessages, сообщений может вернуться меньше, чем должно было. Здесь можно дополнительно на стороне сервера реализовать логику, чтобы в случае прыжка к «краю» истории, с «одной стороны» добавлять столько сообщений, сколько с «противоположной» не хватает до «K».

4. Слабые Android-устройства

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

const [messages, setMessages] = useState(data);
const listRef = useRef();
const scrollToMessage = (index) => this.listRef.current?.scrollToIndex({index});


const jumpToMessage = (targetMessageId) => {
  const result = post('loadSurroundingMessages', {
    messageId: targetMessageId
  });
  setMessages(result.data);
  const targetIndex = findMessageIndexInStateById(targetMessageId);
  // СООБЩЕНИЯ НЕ УСПЕЛИ ОТРИСОВАТЬСЯ
  // scrollToIndex НЕ ДОЕДЕТ
  // ДО НУЖНОГО СООБЩЕНИЯ
  scrollToMessage(targetIndex);
}

return <FlatList ref={listRef} data={allMessagesInChat} />

Для этого можно воспользоваться onViewableItemsChanged — после первого вызова scrollToMessage(), onViewableItemsChanged среагирует на изменения во вьюпорте и вызовет коллбэк, внутри которого можно проверять, дошли ли мы до нужного сообщения. Если не дошли, следует вызывать scrollToMessage повторно.

5. onEndReached — есть, а где взять onStartReached?

В стандартной библиотеке списков React Native уже давно существует свойство onEndReached — в него можно передать метод, который будет вызван при достижении окончания списка. Для реализации ленты чата такой метод необходим. Но также нужен противоположный ему — onStartReached, который будет использоваться при скролле к началу чата после прыжка.

Если вы используете React Native версии 0.72 и выше, то в вашем распоряжении уже есть метод onStartReached. Но как же быть тем, кто ещё не обновился и пока не планирует? В таком случае стоит обратиться к реализации библиотеки react-native-bidirectional-infinite-scroll. Авторы библиотеки применили хитрый приём для отслеживания позиции скролла, использовав onScroll — общеизвестное свойство стандартной библиотеки React Native.

// упрощенная версия кода из репозитория react-native-bidirectional-infinite-scroll
const handleScroll = (event) => {
      // вызываем операции из переданного метода onScroll в первую очередь.
      onScroll?.(event);

      // считаем отступы
      const offset = event.nativeEvent.contentOffset.y;
      const visibleLength = event.nativeEvent.layoutMeasurement.height;
      const contentLength = event.nativeEvent.contentSize.height;

      // определяем сверху или снизу находится скролл.
      const isScrollAtStart = offset < onStartReachedThreshold;
      const isScrollAtEnd =
        contentLength - visibleLength - offset < onEndReachedThreshold;

      if (isScrollAtStart) {
        // вызываем метод для начала списка - onStartReached
      }

      if (isScrollAtEnd) {
        // вызываем метод для конца списка - onEndReached
      }
};

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

Подход достаточно прямолинейный, но легко подстраиваемый под конкретные нужды.

Итог

Разумеется, учитывая специфику различных мессенджеров, при реализации прыжка к сообщению разработчики могут столкнуться с куда большим количеством нюансов. Но описанный выше механизм достаточно универсален и может быть использован для большей части сценариев прыжка. Также данный механизм можно использовать для открытия чата на первом непрочитанном сообщении — достаточно будет в jumpToMessage() передать id первого непрочитанного сообщения и внести несколько модификаций для лучшего UI: не показывать подскролл, а перекрыть его каким-нибудь лоадером, например. Или же не делать подскролл анимированным.

Можно улучшить и сам прыжок — сперва пытаться грузить «сообщения вокруг» без похода на сервер. То есть изобрести loadSurroundingMessages для локальной базы. Но придётся проверять, что в пачке загруженных сообщений нет сообщения с isLastMessageInBatch: true.

Уверены, можно придумать что-то ещё: мы продолжаем активно развивать продукт и будем держать в курсе наших новых технических решений.

Узнать больше о Squadus, цифровом рабочем пространстве от МойОфис, вы можете по ссылке.

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


  1. ofdbsmbqzatyiu
    28.09.2023 13:16

    Насколько вся эта тема ложиться с поиском по сообщениям? или у вас такого нет?


    1. may_err Автор
      28.09.2023 13:16
      +1

      Спасибо за вопрос)
      У нас есть поиск и прыжок к сообщению из него.

      Алгоритм, описанный в статье - универсальный, и целевой id сообщения можно передать откуда угодно, из списка чатов или со страницы поиска.
      Можно в useEffect или в componentDidUpdate вызватьjumpToMessage с полученным идентификатором сообщения.


  1. Lev3250
    28.09.2023 13:16
    +1

    Кто-нибудь, покажите это разрабам тимса. Более убогого поиска и перехода по сообщениям я никогда не видел! Пару раз была задача найти сообщение прошлого, позапрошлого года... квест на 2 часа