mirror of
https://github.com/discourse/discourse.git
synced 2025-06-04 19:14:43 +08:00
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:
@ -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">
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
@ -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 }
|
||||
|
@ -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}}
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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" });
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user