msg pagination and scroll manager
parent
c1d4649ddd
commit
ac0c8ead2e
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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() });
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue