WIP: threads list pagination (#22502)

This implementation will need more work in the future. For simplification of tracking and other events (new thread, delete/restore OM...) we used the threads from `threadsManager` which makes pagination more complicated as we already have some results when we start.

Note this commit also simplify `Collection` to only have one `load` method which can be called repeatedly.
This commit is contained in:
Joffrey JAFFEUX
2023-07-12 09:38:44 +02:00
committed by GitHub
parent 8e63244e72
commit aca0bf69ef
19 changed files with 396 additions and 256 deletions

View File

@ -52,7 +52,7 @@
{{#if
(and
(not this.channelsCollection.length) (not this.channelsCollection.loading)
this.channelsCollection.fetchedOnce (not this.channelsCollection.length)
)
}}
<div class="empty-state">
@ -61,14 +61,14 @@
<p>{{i18n "chat.empty_state.direct_message"}}</p>
<DButton
@action={{this.showChatNewMessageModal}}
label="chat.empty_state.direct_message_cta"
@label="chat.empty_state.direct_message_cta"
/>
</div>
</div>
{{else if this.channelsCollection.length}}
<LoadMore
@selector=".chat-channel-card"
@action={{this.channelsCollection.loadMore}}
@action={{this.channelsCollection.load}}
>
<div class="chat-browse-view__content_wrapper">
<div class="chat-browse-view__content">

View File

@ -50,7 +50,7 @@ export default class ChatBrowseView extends Component {
onScroll() {
discourseDebounce(
this,
this.channelsCollection.loadMore,
this.channelsCollection.load,
{ filter: this.filter, status: this.status },
INPUT_DELAY
);
@ -58,6 +58,8 @@ export default class ChatBrowseView extends Component {
@action
debouncedFiltering(event) {
this.set("channelsCollection", this.chatApi.channels());
discourseDebounce(
this,
this.channelsCollection.load,

View File

@ -1,8 +1,5 @@
{{#if (gt this.channel.membershipsCount 0)}}
<LoadMore
@selector=".channel-members-view__list-item"
@action={{this.loadMore}}
>
<LoadMore @selector=".channel-members-view__list-item" @action={{this.load}}>
<div class="channel-members-view-wrapper">
<div
class={{concat
@ -27,9 +24,11 @@
<ChatUserInfo @user={{membership.user}} />
</div>
{{else}}
{{#unless this.isFetchingMembers}}
{{i18n "chat.channel.no_memberships_found"}}
{{/unless}}
{{#if this.members.fetchedOnce}}
<div class="chat-thread-list__no-threads">
{{i18n "chat.channel.no_memberships_found"}}
</div>
{{/if}}
{{/each}}
</div>
</div>

View File

@ -43,6 +43,7 @@ export default class ChatChannelMembersView extends Component {
@action
onFilterMembers(username) {
this.set("filter", username);
this.set("members", this.chatApi.listChannelMemberships(this.channel.id));
discourseDebounce(
this,
@ -53,8 +54,8 @@ export default class ChatChannelMembersView extends Component {
}
@action
loadMore() {
discourseDebounce(this, this.members.loadMore, INPUT_DELAY);
load() {
discourseDebounce(this, this.members.load, INPUT_DELAY);
}
_focusSearch() {

View File

@ -216,7 +216,7 @@ export default class ChatLivePane extends Component {
if (result.threads) {
result.threads.forEach((thread) => {
const storedThread = this.args.channel.threadsManager.store(
const storedThread = this.args.channel.threadsManager.add(
this.args.channel,
thread,
{ replace: true }
@ -332,7 +332,7 @@ export default class ChatLivePane extends Component {
if (result.threads) {
result.threads.forEach((thread) => {
const storedThread = this.args.channel.threadsManager.store(
const storedThread = this.args.channel.threadsManager.add(
this.args.channel,
thread,
{ replace: true }

View File

@ -12,17 +12,27 @@
{{/if}}
<div class="chat-thread-list__items">
{{#if this.loading}}
{{loading-spinner size="medium"}}
{{#each this.sortedThreads as |thread|}}
<Chat::ThreadList::Item
@thread={{thread}}
{{chat/track-message
(if
(eq thread this.sortedThreads.lastObject)
this.loadThreads
(fn (noop))
)
}}
/>
{{else}}
{{#each this.sortedThreads as |thread|}}
<Chat::ThreadList::Item @thread={{thread}} />
{{else}}
{{#if this.threadsCollection.fetchedOnce}}
<div class="chat-thread-list__no-threads">
{{i18n "chat.threads.none"}}
</div>
{{/each}}
{{/if}}
{{/if}}
{{/each}}
<ConditionalLoadingSpinner
@condition={{this.threadsCollection.loading}}
/>
</div>
</div>
{{/if}}

View File

@ -1,24 +1,25 @@
import Component from "@glimmer/component";
import { bind } from "discourse-common/utils/decorators";
import { tracked } from "@glimmer/tracking";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatThreadList extends Component {
@service chat;
@service chatApi;
@service messageBus;
@service chatTrackingStateManager;
@tracked loading = true;
get threadsManager() {
return this.args.channel.threadsManager;
}
// NOTE: This replicates sort logic from the server. We need this because
// the thread unread count + last reply date + time update when new messages
// are sent to the thread, and we want the list to react in realtime to this.
get sortedThreads() {
if (!this.args.channel.threadsManager.threads) {
return [];
}
return this.args.channel.threadsManager.threads
return this.threadsManager.threads
.filter((thread) => !thread.originalMessage.deletedAt)
.sort((threadA, threadB) => {
// If both are unread we just want to sort by last reply date + time descending.
if (threadA.tracking.unreadCount && threadB.tracking.unreadCount) {
@ -50,14 +51,18 @@ export default class ChatThreadList extends Component {
} else {
return 1;
}
})
.filter((thread) => !thread.originalMessage.deletedAt);
});
}
get shouldRender() {
return !!this.args.channel;
}
@action
loadThreads() {
return this.threadsCollection.load({ limit: 10 });
}
@action
subscribe() {
this.#unsubscribe();
@ -82,11 +87,10 @@ export default class ChatThreadList extends Component {
}
handleDeleteMessage(data) {
const deletedOriginalMessageThread =
this.args.channel.threadsManager.threads.findBy(
"originalMessage.id",
data.deleted_id
);
const deletedOriginalMessageThread = this.threadsManager.threads.findBy(
"originalMessage.id",
data.deleted_id
);
if (!deletedOriginalMessageThread) {
return;
@ -96,11 +100,10 @@ export default class ChatThreadList extends Component {
}
handleRestoreMessage(data) {
const restoredOriginalMessageThread =
this.args.channel.threadsManager.threads.findBy(
"originalMessage.id",
data.chat_message.id
);
const restoredOriginalMessageThread = this.threadsManager.threads.findBy(
"originalMessage.id",
data.chat_message.id
);
if (!restoredOriginalMessageThread) {
return;
@ -109,17 +112,29 @@ export default class ChatThreadList extends Component {
restoredOriginalMessageThread.originalMessage.deletedAt = null;
}
@action
loadThreads() {
this.loading = true;
this.args.channel.threadsManager.index(this.args.channel.id).finally(() => {
this.loading = false;
@cached
get threadsCollection() {
return this.chatApi.threads(this.args.channel.id, this.handleLoadedThreads);
}
@bind
handleLoadedThreads(result) {
return result.threads.map((thread) => {
const threadModel = this.threadsManager.add(this.args.channel, thread, {
replace: true,
});
this.chatTrackingStateManager.setupChannelThreadState(
this.args.channel,
result.tracking
);
return threadModel;
});
}
@action
teardown() {
this.loading = true;
this.#unsubscribe();
}

View File

@ -4,6 +4,7 @@
(if (gt @thread.tracking.unreadCount 0) "-is-unread")
}}
data-thread-id={{@thread.id}}
...attributes
>
<div class="chat-thread-list-item__main">
<div

View File

@ -2,7 +2,7 @@ import { inject as service } from "@ember/service";
import { setOwner } from "@ember/application";
import Promise from "rsvp";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import { tracked } from "@glimmer/tracking";
import { cached, tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
/*
@ -14,57 +14,37 @@ import { TrackedObject } from "@ember-compat/tracked-built-ins";
*/
export default class ChatThreadsManager {
@service chatSubscriptionsManager;
@service chatTrackingStateManager;
@service chatChannelsManager;
@service chatApi;
@service chat;
@service currentUser;
@tracked _cached = new TrackedObject();
constructor(owner) {
setOwner(this, owner);
}
@cached
get threads() {
return Object.values(this._cached);
}
async find(channelId, threadId, options = { fetchIfNotFound: true }) {
const existingThread = this.#findStale(threadId);
const existingThread = this.#getFromCache(threadId);
if (existingThread) {
return Promise.resolve(existingThread);
} else if (options.fetchIfNotFound) {
return this.#find(channelId, threadId);
return this.#fetchFromServer(channelId, threadId);
} else {
return Promise.resolve();
}
}
async index(channelId) {
return this.chatChannelsManager.find(channelId).then((channel) => {
return this.#loadIndex(channelId).then((result) => {
const threads = result.threads.map((thread) => {
return channel.threadsManager.store(channel, thread, {
replace: true,
});
});
this.chatTrackingStateManager.setupChannelThreadState(
channel,
result.tracking
);
return { threads, meta: result.meta };
});
});
}
get threads() {
return Object.values(this._cached);
}
store(channel, threadObject, options = {}) {
add(channel, threadObject, options = {}) {
let model;
if (!options.replace) {
model = this.#findStale(threadObject.id);
model = this.#getFromCache(threadObject.id);
}
if (!model) {
@ -88,23 +68,19 @@ export default class ChatThreadsManager {
return model;
}
async #find(channelId, threadId) {
return this.chatApi.thread(channelId, threadId).then((result) => {
return this.chatChannelsManager.find(channelId).then((channel) => {
return channel.threadsManager.store(channel, result.thread);
});
});
}
#cache(thread) {
this._cached[thread.id] = thread;
}
#findStale(id) {
#getFromCache(id) {
return this._cached[id];
}
async #loadIndex(channelId) {
return this.chatApi.threads(channelId);
async #fetchFromServer(channelId, threadId) {
return this.chatApi.thread(channelId, threadId).then((result) => {
return this.chatChannelsManager.find(channelId).then((channel) => {
return channel.threadsManager.add(channel, result.thread);
});
});
}
}

View File

@ -10,6 +10,7 @@ export default class Collection {
@tracked items = [];
@tracked meta = {};
@tracked loading = false;
@tracked fetchedOnce = false;
constructor(resourceURL, handler) {
this._resourceURL = resourceURL;
@ -18,15 +19,15 @@ export default class Collection {
}
get loadMoreURL() {
return this.meta.load_more_url;
return this.meta?.load_more_url;
}
get totalRows() {
return this.meta.total_rows;
return this.meta?.total_rows;
}
get length() {
return this.items.length;
return this.items?.length;
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
@ -35,7 +36,7 @@ export default class Collection {
return {
next: () => {
if (index < this.items.length) {
if (index < this.length) {
return { value: this.items[index++], done: false };
} else {
return { done: true };
@ -50,69 +51,48 @@ export default class Collection {
*/
@bind
load(params = {}) {
this._fetchedAll = false;
if (this.loading) {
if (
this.loading ||
this._fetchedAll ||
(this.totalRows && this.items.length >= this.totalRows)
) {
return Promise.resolve();
}
this.loading = true;
const filteredQueryParams = Object.entries(params).filter(
([, v]) => v !== undefined
);
const queryString = new URLSearchParams(filteredQueryParams).toString();
let endpoint;
if (this.loadMoreURL) {
endpoint = this.loadMoreURL;
} else {
const filteredQueryParams = Object.entries(params).filter(
([, v]) => v !== undefined
);
const queryString = new URLSearchParams(filteredQueryParams).toString();
endpoint = this._resourceURL + (queryString ? `?${queryString}` : "");
}
const endpoint = this._resourceURL + (queryString ? `?${queryString}` : "");
return this.#fetch(endpoint)
.then((result) => {
this.items = this._handler(result);
const items = this._handler(result);
if (items.length) {
this.items = (this.items ?? []).concat(items);
}
if (!items.length || items.length < params.limit) {
this._fetchedAll = true;
}
this.meta = result.meta;
this.fetchedOnce = true;
})
.finally(() => {
this.loading = false;
});
}
/**
* Attempts to load more results
* @returns {Promise}
*/
@bind
loadMore() {
let promise = Promise.resolve();
if (this.loading) {
return promise;
}
if (
this._fetchedAll ||
(this.totalRows && this.items.length >= this.totalRows)
) {
return promise;
}
this.loading = true;
if (this.loadMoreURL) {
promise = this.#fetch(this.loadMoreURL).then((result) => {
const newItems = this._handler(result);
if (newItems.length) {
this.items = this.items.concat(newItems);
} else {
this._fetchedAll = true;
}
this.meta = result.meta;
});
}
return promise.finally(() => {
this.loading = false;
});
}
#fetch(url) {
return ajax(url, { type: "GET" });
}

View File

@ -269,7 +269,7 @@ export default class ChatChannel {
});
clonedMessage.thread = thread;
this.threadsManager.store(this, thread);
this.threadsManager.add(this, thread);
thread.messagesManager.addMessages([clonedMessage]);
return thread;

View File

@ -81,8 +81,11 @@ export default class ChatApi extends Service {
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*/
threads(channelId) {
return this.#getRequest(`/channels/${channelId}/threads`);
threads(channelId, handler) {
return new Collection(
`${this.#basePath}/channels/${channelId}/threads`,
handler
);
}
/**

View File

@ -34,8 +34,9 @@ export default class ChatTrackingStateManager extends Service {
setupChannelThreadState(channel, threadTracking) {
channel.threadsManager.threads.forEach((thread) => {
if (threadTracking[thread.id.toString()]) {
this.#setState(thread, threadTracking[thread.id.toString()]);
const tracking = threadTracking[thread.id.toString()];
if (tracking) {
this.#setState(thread, tracking);
}
});
}