msg pagination and scroll manager

pull/5963/head
Thibault Duplessis 2020-01-29 12:21:44 -06:00
parent c1d4649ddd
commit ac0c8ead2e
9 changed files with 127 additions and 25 deletions

View File

@ -22,10 +22,10 @@ final class Msg(
)
}
def convo(username: String) = Auth { implicit ctx => me =>
if (username == "new") Redirect(get("user").fold(routes.Msg.home())(routes.Msg.convo)).fuccess
def convo(username: String, before: Option[Long] = None) = Auth { implicit ctx => me =>
if (username == "new") Redirect(get("user").fold(routes.Msg.home())(routes.Msg.convo(_))).fuccess
else
ctx.hasInbox ?? env.msg.api.convoWith(me, username).flatMap {
ctx.hasInbox ?? env.msg.api.convoWith(me, username, before).flatMap {
case None =>
negotiate(
html = Redirect(routes.Msg.home).fuccess,

View File

@ -420,7 +420,7 @@ POST /inbox/new controllers.Msg.compatCreate
GET /inbox controllers.Msg.home
GET /inbox/search controllers.Msg.search(q: String)
GET /inbox/unread-count controllers.Msg.unreadCount
GET /inbox/:username controllers.Msg.convo(username: String)
GET /inbox/:username controllers.Msg.convo(username: String, before: Option[Long] ?= None)
DELETE /inbox/:username controllers.Msg.convoDelete(username: String)
# Msg API/compat
POST /inbox/:username controllers.Msg.apiPost(username: String)

View File

@ -2,8 +2,11 @@ package lila.msg
import reactivemongo.api._
import scala.concurrent.duration._
import org.joda.time.DateTime
import scala.util.Try
import lila.common.{ Bus, LightUser }
import lila.common.config.MaxPerPage
import lila.db.dsl._
import lila.user.User
@ -19,6 +22,8 @@ final class MsgApi(
shutup: lila.hub.actors.Shutup
)(implicit ec: scala.concurrent.ExecutionContext) {
val msgsPerPage = MaxPerPage(100)
import BsonHandlers._
def threadsOf(me: User): Fu[List[MsgThread]] =
@ -27,14 +32,17 @@ final class MsgApi(
.sort($sort desc "lastMsg.date")
.list[MsgThread](50)
def convoWith(me: User, username: String): Fu[Option[MsgConvo]] = {
def convoWith(me: User, username: String, beforeMillis: Option[Long] = None): Fu[Option[MsgConvo]] = {
val userId = User.normalize(username)
val threadId = MsgThread.id(me.id, userId)
val before = beforeMillis flatMap { millis =>
Try(new DateTime(millis)).toOption
}
(userId != me.id) ?? lightUserApi.async(userId).flatMap {
_ ?? { contact =>
for {
_ <- setReadBy(threadId, me)
msgs <- threadMsgsFor(threadId, me)
msgs <- threadMsgsFor(threadId, me, before)
relations <- relationApi.fetchRelations(me.id, userId)
postable <- security.may.post(me.id, userId, isNew = msgs.headOption.isEmpty)
} yield MsgConvo(contact, msgs, relations, postable).some
@ -155,14 +163,16 @@ final class MsgApi(
private val msgProjection = $doc("_id" -> false, "tid" -> false)
private def threadMsgsFor(threadId: MsgThread.Id, me: User): Fu[List[Msg]] =
private def threadMsgsFor(threadId: MsgThread.Id, me: User, before: Option[DateTime]): Fu[List[Msg]] =
colls.msg.ext
.find(
$doc("tid" -> threadId, "del" $ne me.id),
$doc("tid" -> threadId, "del" $ne me.id) ++ before.?? { b =>
$doc("date" $lt b)
},
msgProjection
)
.sort($sort desc "date")
.list[Msg](100)
.list[Msg](msgsPerPage.value)
private def setReadBy(threadId: MsgThread.Id, me: User): Funit =
colls.thread.updateField(

View File

@ -5,6 +5,12 @@
&__init {
flex: 1 1 auto;
}
&__more {
margin: 2em auto;
}
&__getting-more {
flex: 1 1 auto;
}
&__content {
@extend %flex-column, %break-word;
flex: 0 0 auto;
@ -57,6 +63,7 @@
img {
@extend %box-neat;
max-width: 100%;
max-height: 40vh;
}
.has-embed {
max-width: 90%;

View File

@ -1,6 +1,7 @@
import { MsgData, Contact, Msg, LastMsg, Search, SearchResult, Pane, Redraw } from './interfaces';
import notify from 'common/notification';
import * as network from './network';
import { scroller } from './view/scroller';
export default class MsgCtrl {
@ -11,11 +12,14 @@ export default class MsgCtrl {
pane: Pane;
loading = false;
connected = () => true;
msgsPerPage = 100;
canGetMoreSince?: Date;
constructor(data: MsgData, readonly trans: Trans, readonly redraw: Redraw) {
this.data = data;
this.pane = data.convo ? 'convo' : 'side';
this.connected = network.websocketHandler(this);
if (this.data.convo) this.onLoadMsgs(this.data.convo.msgs);
window.addEventListener('focus', this.setRead);
};
@ -30,6 +34,7 @@ export default class MsgCtrl {
this.loading = false;
if (data.convo) {
history.replaceState({contact: userId}, '', `/inbox/${data.convo.user.name}`);
this.onLoadMsgs(data.convo.msgs);
this.redraw();
}
else this.showSide();
@ -43,6 +48,25 @@ export default class MsgCtrl {
this.redraw();
}
getMore = () => {
if (this.data.convo && this.canGetMoreSince)
network.getMore(this.data.convo.user.id, this.canGetMoreSince)
.then(data => {
if (!this.data.convo || !data.convo || data.convo.user.id != this.data.convo.user.id || !data.convo.msgs[0]) return;
if (data.convo.msgs[0].date >= this.data.convo.msgs[this.data.convo.msgs.length - 1].date) return;
this.data.convo.msgs = this.data.convo.msgs.concat(data.convo.msgs);
this.onLoadMsgs(data.convo.msgs);
this.redraw();
});
this.canGetMoreSince = undefined;
this.redraw();
}
private onLoadMsgs = (msgs: Msg[]) => {
const oldFirstMsg = msgs[this.msgsPerPage - 1];
this.canGetMoreSince = oldFirstMsg?.date;
}
post = (text: string) => {
if (this.data.convo) {
network.post(this.data.convo.user.id, text);
@ -59,6 +83,7 @@ export default class MsgCtrl {
this.data.contacts = data.contacts;
this.redraw();
}), 1000);
scroller.enable(true);
this.redraw();
}
}

View File

@ -18,6 +18,10 @@ export function loadConvo(userId: string): Promise<MsgData> {
return xhr(`/inbox/${userId}`).then(upgradeData);
}
export function getMore(userId: string, before: Date): Promise<MsgData> {
return xhr(`/inbox/${userId}?before=${before.getTime()}`).then(upgradeData);
}
export function loadContacts(): Promise<MsgData> {
return xhr(`/inbox`).then(upgradeData);
}

View File

@ -1,3 +1,5 @@
import { scroller } from './scroller';
// looks like it has a @mention or a url.tld
export const isMoreThanText = (str: string) => /(\n|(@|\.)\w{2,})/.test(str);
@ -51,7 +53,7 @@ const domain = window.location.host;
const gameRegex = new RegExp(`(?:https?://)${domain}/(?:embed/)?(\\w{8})(?:(?:/(white|black))|\\w{4}|)(#\\d+)?$`);
const notGames = ['training', 'analysis', 'insights', 'practice', 'features', 'password', 'streamer'];
export function expandIFrames(el: HTMLElement, onLoad: () => void) {
export function expandIFrames(el: HTMLElement) {
const expandables: Expandable[] = [];
@ -63,11 +65,11 @@ export function expandIFrames(el: HTMLElement, onLoad: () => void) {
});
});
expandGames(expandables.filter(e => e.link.type == 'game'), onLoad);
expandGames(expandables.filter(e => e.link.type == 'game'));
}
function expandGames(games: Expandable[], onLoad: () => void): void {
if (games.length < 3) games.forEach(g => expand(g, onLoad));
function expandGames(games: Expandable[]): void {
if (games.length < 3) games.forEach(expand);
else games.forEach(game => {
game.element.title = 'Click to expand';
game.element.classList.add('text');
@ -75,13 +77,13 @@ function expandGames(games: Expandable[], onLoad: () => void): void {
game.element.addEventListener('click', e => {
if (e.button === 0) {
e.preventDefault();
expand(game, onLoad);
expand(game);
}
});
});
}
function expand(exp: Expandable, onLoad: () => void): void {
function expand(exp: Expandable): void {
const $iframe: any = $('<iframe>').attr('src', exp.link.src);
$(exp.element).parent().parent().addClass('has-embed');
$(exp.element).replaceWith($('<div class="embed"></div>').html($iframe));
@ -89,7 +91,7 @@ function expand(exp: Expandable, onLoad: () => void): void {
.on('load', function(this: HTMLIFrameElement) {
if (this.contentDocument?.title.startsWith("404"))
(this.parentNode as HTMLElement).classList.add('not-found');
onLoad();
scroller.auto();
})
.on('mouseenter', function(this: HTMLIFrameElement) { $(this).focus() });
}

View File

@ -2,6 +2,8 @@ import { h } from 'snabbdom'
import { VNode } from 'snabbdom/vnode'
import { Msg, Daily } from '../interfaces'
import * as enhance from './enhance';
import { scroller } from './scroller';
import { bind } from './util';
import MsgCtrl from '../ctrl';
export default function renderMsgs(ctrl: MsgCtrl, msgs: Msg[]): VNode {
@ -12,7 +14,15 @@ export default function renderMsgs(ctrl: MsgCtrl, msgs: Msg[]): VNode {
}
}, [
h('div.msg-app__convo__msgs__init'),
h('div.msg-app__convo__msgs__content', contentMsgs(ctrl, msgs))
h('div.msg-app__convo__msgs__content', [
ctrl.canGetMoreSince ? h('button.msg-app__convo__msgs__more.button.button-empty', {
hook: bind('click', _ => {
scroller.setMarker();
ctrl.getMore();
})
}, 'Load more') : null,
...contentMsgs(ctrl, msgs)
])
]);
}
@ -25,9 +35,13 @@ function contentMsgs(ctrl: MsgCtrl, msgs: Msg[]): VNode[] {
function renderDaily(ctrl: MsgCtrl, daily: Daily): VNode[] {
return [
h('day', renderDate(daily.date)),
h('day', {
key: `d${daily.date.getTime()}`
}, renderDate(daily.date)),
...daily.msgs.map(group =>
h('group', group.map(msg => renderMsg(ctrl, msg)))
h('group', {
key: `g${daily.date.getTime()}`
}, group.map(msg => renderMsg(ctrl, msg)))
)
];
}
@ -76,8 +90,6 @@ function sameDay(d: Date, e: Date) {
return d.getDate() == e.getDate() && d.getMonth() == e.getMonth() && d.getFullYear() == e.getFullYear();
}
let autoscroll = () => {};
function renderText(msg: Msg) {
return enhance.isMoreThanText(msg.text) ? h('t', {
hook: {
@ -85,7 +97,7 @@ function renderText(msg: Msg) {
const el = (vnode.elm as HTMLElement);
el.innerHTML = enhance.enhance(msg.text);
el.querySelectorAll('img').forEach(c =>
c.addEventListener('load', _ => autoscroll(), { once: true })
c.addEventListener('load', scroller.auto, { once: true })
);
}
}
@ -94,7 +106,7 @@ function renderText(msg: Msg) {
const setupMsgs = (insert: boolean) => (vnode: VNode) => {
const el = (vnode.elm as HTMLElement);
if (insert) autoscroll = () => requestAnimationFrame(() => el.scrollTop = 9999999);
enhance.expandIFrames(el, autoscroll);
autoscroll();
if (insert) scroller.init(el);
enhance.expandIFrames(el);
scroller.toMarker() || scroller.auto();
}

View File

@ -0,0 +1,42 @@
import throttle from 'common/throttle';
class Scroller {
enabled: boolean = false;
element?: HTMLElement;
marker?: HTMLElement;
init = (e: HTMLElement) => {
this.enabled = true;
this.element = e;
this.element.addEventListener('scroll', throttle(500, _ => {
const el = this.element;
this.enable(!!el && el.offsetHeight + el.scrollTop > el.scrollHeight - 20);
}), { passive: true });
window.el = this.element;
}
auto = () => {
if (this.element && this.enabled)
requestAnimationFrame(() => this.element!.scrollTop = 9999999);
}
enable = (v: boolean) => { this.enabled = v; }
setMarker = () => {
this.marker = this.element && this.element.querySelector('mine,their') as HTMLElement;
}
toMarker = (): boolean => {
if (this.marker && this.to(this.marker)) {
this.marker = undefined;
return true;
}
return false;
}
to = (target: HTMLElement) => {
if (this.element) {
const top = target.offsetTop - this.element.offsetHeight / 2 + target.offsetHeight / 2;
if (top > 0) this.element.scrollTop = top;
return top > 0;
}
return false;
}
}
export const scroller = new Scroller;