Compare commits

...
This repository has been archived on 2023-03-18. You can view files and clone it, but cannot push or open issues or pull requests.

4 Commits

Author SHA1 Message Date
Martin Brennan
b95d9a1d4a
DEV: Sketch of how the chat thread panel will work with new routes 2023-02-07 13:15:23 +10:00
Joffrey JAFFEUX
0517544c23 fix tests 2023-02-06 21:55:27 +01:00
Joffrey JAFFEUX
0f49fb0b95 fix spec 2023-02-06 20:23:35 +01:00
Joffrey JAFFEUX
62685c1a76 DEV: refactors routes to simplify using outlet
This work will allow us to have an {{outlet}} chat.channel route and use it for threads as a sidepanel.
2023-02-06 14:27:35 +01:00
32 changed files with 346 additions and 98 deletions

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Chat::Api::ChatThreadsController < Chat::Api
def show
render json:
success_json.merge(
{
thread: {
id: params[:thread_id],
original_message_user: {
username: "test",
},
original_message_excerpt: "this is a cool message",
},
},
)
end
end

View File

@ -6,15 +6,19 @@ export default function () {
});
this.route("channel", { path: "/c/:channelTitle/:channelId" }, function () {
this.route("from-params", { path: "/" });
this.route("near-message", { path: "/:messageId" });
this.route("thread", { path: "/t/:threadId" });
});
this.route("info", { path: "/info" }, function () {
this.route(
"channel.info",
{ path: "/c/:channelTitle/:channelId/info" },
function () {
this.route("about", { path: "/about" });
this.route("members", { path: "/members" });
this.route("settings", { path: "/settings" });
});
});
}
);
this.route("draft-channel", { path: "/draft-channel" });
this.route("browse", { path: "/browse" }, function () {

View File

@ -10,7 +10,7 @@
>
<div class="chat-channel-card__header">
<LinkTo
@route="chat.channel.from-params"
@route="chat.channel"
@models={{@channel.routeModels}}
class="chat-channel-card__name-container"
>

View File

@ -1,5 +1,5 @@
<LinkTo
@route="chat.channel.from-params"
@route="chat.channel"
@models={{@channel.routeModels}}
class={{concat-class
"chat-channel-row"

View File

@ -236,9 +236,9 @@ export default Component.extend({
this.set("view", DRAFT_CHANNEL_VIEW);
this.appEvents.trigger("chat:float-toggled", false);
return;
case "chat.channel.from-params":
case "chat.channel":
return this._openChannel(
route.parent.params.channelId,
route.params.channelId,
this._highlightCb(route.queryParams.messageId)
);
case "chat.channel.near-message":

View File

@ -15,7 +15,7 @@
{{/if}}
<LinkTo
@route={{this.infoTabRoute}}
@route="chat.channel.info"
@models={{this.chatChannel.routeModels}}
class="chat-channel-title-wrapper"
>

View File

@ -574,6 +574,8 @@ export default Component.extend({
return;
}
this.set("targetMessageId", messageId);
if (this.messageLookup[messageId]) {
// We have the message rendered. highlight and scrollTo
this.scrollToMessage(messageId, {
@ -582,7 +584,6 @@ export default Component.extend({
autoExpand: true,
});
} else {
this.set("targetMessageId", messageId);
this.fetchMessages(this.chatChannel);
}
},

View File

@ -80,6 +80,15 @@
/>
</div>
{{else}}
{{#if this.message.in_reply_to}}
<LinkTo
@route={{"chat.channel.thread"}}
@model={{this.message.id}}
class="chat-thread-link"
>
View Thread
</LinkTo>
{{/if}}
<div class={{this.chatMessageClasses}}>
{{#if this.message.in_reply_to}}
<div

View File

@ -0,0 +1,16 @@
<div
class={{concat-class
"chat-thread-panel"
(if this.chat.activeThread "chat-thread-pane--active-thread")
}}
>
<p>Thread ID
{{this.chat.activeThread.id}}, started by
{{this.chat.activeThread.original_message_user.username}}</p>
<p>Excerpt: {{this.chat.activeThread.original_message_excerpt}}</p>
<LinkTo @route="chat.channel" @models={{this.chat.activeChannel.routeModels}}>
Close Thread
</LinkTo>
</div>

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class ChatThreadPanel extends Component {
@service siteSettings;
@service currentUser;
@service chat;
@service router;
@action
closeThread() {
return this.router.transitionTo("chat.channel", {
channelId: this.chat.activeChannel.id,
});
}
}

View File

@ -3,5 +3,6 @@
@chatChannel={{this.chat.activeChannel}}
@onBackClick={{action "navigateToIndex"}}
@onSwitchChannel={{action "switchChannel"}}
@targetMessageId={{readonly @targetMessageId}}
/>
{{/if}}

View File

@ -5,6 +5,8 @@ import { inject as service } from "@ember/service";
export default class ChatChannelController extends Controller {
@service chat;
targetMessageId = null;
// Backwards-compatibility
queryParams = ["messageId"];

View File

@ -52,7 +52,7 @@ export default {
}
get route() {
return "chat.channel.from-params";
return "chat.channel";
}
get models() {
@ -215,7 +215,7 @@ export default {
}
get route() {
return "chat.channel.from-params";
return "chat.channel";
}
get models() {

View File

@ -0,0 +1,77 @@
import RestModel from "discourse/models/rest";
import I18n from "I18n";
import User from "discourse/models/user";
import { escapeExpression } from "discourse/lib/utilities";
import { tracked } from "@glimmer/tracking";
export const THREAD_STATUSES = {
open: "open",
readOnly: "read_only",
closed: "closed",
archived: "archived",
};
export function threadStatusName(status) {
switch (status) {
case THREAD_STATUSES.open:
return I18n.t("chat.thread_status.open");
case THREAD_STATUSES.readOnly:
return I18n.t("chat.thread_status.read_only");
case THREAD_STATUSES.closed:
return I18n.t("chat.thread_status.closed");
case THREAD_STATUSES.archived:
return I18n.t("chat.thread_status.archived");
}
}
const READONLY_STATUSES = [
THREAD_STATUSES.closed,
THREAD_STATUSES.readOnly,
THREAD_STATUSES.archived,
];
const STAFF_READONLY_STATUSES = [
THREAD_STATUSES.readOnly,
THREAD_STATUSES.archived,
];
export default class ChatThread extends RestModel {
@tracked title;
@tracked status;
get escapedTitle() {
return escapeExpression(this.title);
}
get isOpen() {
return !this.status || this.status === THREAD_STATUSES.open;
}
get isReadOnly() {
return this.status === THREAD_STATUSES.readOnly;
}
get isClosed() {
return this.status === THREAD_STATUSES.closed;
}
get isArchived() {
return this.status === THREAD_STATUSES.archived;
}
canModifyMessages(user) {
if (user.staff) {
return !STAFF_READONLY_STATUSES.includes(this.status);
}
return !READONLY_STATUSES.includes(this.status);
}
}
ChatThread.reopenClass({
create(args) {
args = args || {};
args.original_message_user = User.create(args.original_message_user);
return this._super(args);
},
});

View File

@ -0,0 +1,42 @@
import { inject as service } from "@ember/service";
export default function withChatChannel(extendedClass) {
return class WithChatChannel extends extendedClass {
@service chatChannelsManager;
@service chat;
@service router;
async model(params) {
return this.chatChannelsManager.find(params.channelId);
}
afterModel(model) {
this.controllerFor("chat-channel").set("targetMessageId", null);
this.chat.setActiveChannel(model);
let { messageId } = this.paramsFor(this.routeName);
// messageId query param backwards-compatibility
if (messageId) {
this.router.replaceWith(
"chat.channel",
...model.routeModels,
messageId
);
}
const { channelTitle } = this.paramsFor("chat.channel");
if (channelTitle && channelTitle !== model.slugifiedTitle) {
const nearMessageParams = this.paramsFor("chat.channel.near-message");
if (nearMessageParams.messageId) {
this.router.replaceWith(
"chat.channel.near-message",
...model.routeModels,
nearMessageParams.messageId
);
} else {
this.router.replaceWith("chat.channel", ...model.routeModels);
}
}
}
};
}

View File

@ -1,18 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
export default class ChatChannelFromParamsRoute extends DiscourseRoute {
@service router;
async model() {
return this.modelFor("chat-channel");
}
afterModel(model) {
const { channelTitle } = this.paramsFor("chat.channel");
if (channelTitle !== model.slugifiedTitle) {
this.router.replaceWith("chat.channel.from-params", ...model.routeModels);
}
}
}

View File

@ -1,7 +1,9 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
import { ORIGINS } from "discourse/plugins/chat/discourse/services/chat-channel-info-route-origin-manager";
import withChatChannel from "./chat-channel-decorator";
@withChatChannel
export default class ChatChannelInfoRoute extends DiscourseRoute {
@service chatChannelInfoRouteOriginManager;

View File

@ -9,13 +9,8 @@ export default class ChatChannelLegacyRoute extends DiscourseRoute {
this.routeName
);
this.router.replaceWith(
"chat.channel.from-params",
channelTitle,
channelId,
{
queryParams: { messageId },
}
);
this.router.replaceWith("chat.channel", channelTitle, channelId, {
queryParams: { messageId },
});
}
}

View File

@ -1,41 +1,16 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
// This route is only here as a convience method for a clean `/c/:channelTitle/:channelId/:messageId` URL.
// It's not a real route, it just redirects to the real route after setting a param on the controller.
export default class ChatChannelNearMessage extends DiscourseRoute {
@service chat;
@service router;
async model() {
return this.modelFor("chat-channel");
}
afterModel(model) {
beforeModel() {
const channel = this.modelFor("chat-channel");
const { messageId } = this.paramsFor(this.routeName);
const { channelTitle } = this.paramsFor("chat.channel");
if (channelTitle !== model.slugifiedTitle) {
this.router.replaceWith(
"chat.channel.near-message",
...model.routeModels,
messageId
);
}
}
@action
didTransition() {
this.controllerFor("chat-channel").set("messageId", null);
const { messageId } = this.paramsFor(this.routeName);
const { channelId } = this.paramsFor("chat.channel");
if (channelId && messageId) {
schedule("afterRender", () => {
this.chat.openChannelAtMessage(channelId, messageId);
});
}
return true;
this.controllerFor("chat-channel").set("targetMessageId", messageId);
this.router.replaceWith("chat.channel", ...channel.routeModels);
}
}

View File

@ -0,0 +1,16 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
export default class ChatChannelThread extends DiscourseRoute {
@service router;
@service chatThreadsManager;
@service chat;
async model(params) {
return this.chatThreadsManager.find(params.threadId);
}
afterModel(model) {
this.chat.setActiveThread(model);
}
}

View File

@ -1,27 +1,12 @@
import DiscourseRoute from "discourse/routes/discourse";
import withChatChannel from "./chat-channel-decorator";
import { inject as service } from "@ember/service";
@withChatChannel
export default class ChatChannelRoute extends DiscourseRoute {
@service chatChannelsManager;
@service chat;
@service router;
@service chatThreadsManager;
async model(params) {
return this.chatChannelsManager.find(params.channelId);
}
afterModel(model) {
this.chat.setActiveChannel(model);
const { messageId } = this.paramsFor(this.routeName);
// messageId query param backwards-compatibility
if (messageId) {
this.router.replaceWith(
"chat.channel.near-message",
...model.routeModels,
messageId
);
}
beforeModel() {
this.chatThreadsManager.resetCache();
}
}

View File

@ -22,7 +22,7 @@ export default class ChatRoute extends DiscourseRoute {
const INTERCEPTABLE_ROUTES = [
"chat.channel",
"chat.channel.from-params",
"chat.channel.index",
"chat.channel.near-message",
"chat.channel-legacy",
"chat",

View File

@ -13,6 +13,7 @@ import Collection from "../lib/collection";
*/
export default class ChatApi extends Service {
@service chatChannelsManager;
@service chatThreadsManager;
/**
* Get a channel by its ID.
@ -29,6 +30,21 @@ export default class ChatApi extends Service {
);
}
/**
* Get a thread by its ID.
* @param {number} threadId - The ID of the thread.
* @returns {Promise}
*
* @example
*
* this.chatApi.thread(1).then(thread => { ... })
*/
thread(threadId) {
return this.#getRequest(`/threads/${threadId}`).then((result) =>
this.chatThreadsManager.store(result.thread)
);
}
/**
* List all accessible category channels of the current user.
* @returns {module:Collection}

View File

@ -0,0 +1,70 @@
import Service, { inject as service } from "@ember/service";
import Promise from "rsvp";
import ChatThread from "discourse/plugins/chat/discourse/models/chat-thread";
import { tracked } from "@glimmer/tracking";
import { TrackedObject } from "@ember-compat/tracked-built-ins";
import { popupAjaxError } from "discourse/lib/ajax-error";
/*
The ChatThreadsManager service is responsible for managing the loaded chat threads
for the current chat channel.
It provides helpers to facilitate using and managing loaded threads instead of constantly
fetching them from the server.
*/
export default class ChatThreadsManager extends Service {
@service chatSubscriptionsManager;
@service chatApi;
@service currentUser;
@tracked _cached = new TrackedObject();
async find(id, options = { fetchIfNotFound: true }) {
const existingThread = this.#findStale(id);
if (existingThread) {
return Promise.resolve(existingThread);
} else if (options.fetchIfNotFound) {
return this.#find(id);
} else {
return Promise.resolve();
}
}
// whenever the active channel changes, do this
resetCache() {
this._cached = new TrackedObject();
}
get threads() {
return Object.values(this._cached);
}
store(threadObject) {
let model = this.#findStale(threadObject.id);
if (!model) {
model = ChatThread.create(threadObject);
this.#cache(model);
}
return model;
}
async #find(id) {
return this.chatApi
.thread(id)
.catch(popupAjaxError)
.then((thread) => {
this.#cache(thread);
return thread;
});
}
#cache(thread) {
this._cached[thread.id] = thread;
}
#findStale(id) {
return this._cached[id];
}
}

View File

@ -36,6 +36,7 @@ export default class Chat extends Service {
@service chatChannelsManager;
activeChannel = null;
activeThread = null;
cook = null;
presenceChannel = null;
sidebarActive = false;
@ -120,6 +121,10 @@ export default class Chat extends Service {
this.set("activeChannel", channel);
}
setActiveThread(thread) {
this.set("activeThread", thread);
}
loadCookFunction(categories) {
if (this.cook) {
return Promise.resolve(this.cook);
@ -277,7 +282,7 @@ export default class Chat extends Service {
async _openFoundChannelAtMessage(channel, messageId = null) {
if (
(this.router.currentRouteName === "chat.channel.from-params" ||
(this.router.currentRouteName === "chat.channel" ||
this.router.currentRouteName === "chat.channel.near-message") &&
this.activeChannel?.id === channel.id
) {
@ -300,10 +305,7 @@ export default class Chat extends Service {
messageId
);
} else {
return this.router.transitionTo(
"chat.channel.from-params",
...channel.routeModels
);
return this.router.transitionTo("chat.channel", ...channel.routeModels);
}
} else {
this._fireOpenFloatAppEvent(channel, messageId);

View File

@ -12,7 +12,7 @@
</LinkTo>
{{else}}
<LinkTo
@route="chat.channel.from-params"
@route="chat.channel"
@models={{this.model.routeModels}}
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.channel_info.back_to_channel"}}

View File

@ -0,0 +1 @@
<ChatThreadPanel />

View File

@ -1 +1,4 @@
{{outlet}}
<FullPageChat @targetMessageId={{this.targetMessageId}} />
<div id="thread-panel-outlet">
{{outlet}}
</div>

View File

@ -0,0 +1,8 @@
.chat-thread-pane {
background-color: #aee6bd;
display: none;
&--active-thread {
display: block;
}
}

View File

@ -590,6 +590,7 @@ html.has-full-page-chat {
#main-chat-outlet {
min-height: 0;
display: flex;
}
}
}

View File

@ -26,6 +26,7 @@ register_asset "stylesheets/common/chat-channel-preview-card.scss"
register_asset "stylesheets/common/chat-channel-info.scss"
register_asset "stylesheets/common/chat-draft-channel.scss"
register_asset "stylesheets/common/chat-tabs.scss"
register_asset "stylesheets/common/chat-thread-panel.scss"
register_asset "stylesheets/common/chat-form.scss"
register_asset "stylesheets/common/d-progress-bar.scss"
register_asset "stylesheets/common/incoming-chat-webhooks.scss"
@ -228,6 +229,7 @@ after_initialize do
)
load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_threads_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__)
load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__)
@ -596,6 +598,8 @@ after_initialize do
# Hints for JIT warnings.
get "/mentions/groups" => "hints#check_group_mentions", :format => :json
get "/threads/:thread_id" => "chat_threads#show"
end
# direct_messages_controller routes
@ -648,6 +652,8 @@ after_initialize do
# /channel -> /c redirects
get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}")
get "#{base_c_route}/t/:thread_id" => "chat#respond"
base_channel_route = "/channel/:channel_id/:channel_title"
redirect_base = "/chat/c/%{channel_title}/%{channel_id}"