DEV: Add more-topics plugin API (#29143)

From plugin-api comment:

Registers a new tab to be displayed in "more topics" area at the bottom of a topic page.

```gjs
 api.registerMoreTopicsTab({
   id: "other-topics",
   name: i18n("other_topics.tab"),
   component: <template>tbd</template>,
   condition: ({ topic }) => topic.otherTopics?.length > 0,
 });
```

You can additionally use more-topics-tabs value transformer to conditionally show/hide
specific tabs.

```js
api.registerValueTransformer("more-topics-tabs", ({ value, context }) => {
  if (context.user?.aFeatureFlag) {
    // Remove "suggested" from the topics page
    return value.filter(
      (tab) =>
        context.currentContext !== "topic" ||
        tab.id !== "suggested-topics"
    );
  }
});
```
This commit is contained in:
Jarek Radosz 2024-11-04 17:32:49 +01:00 committed by GitHub
parent 7a936da05c
commit 9c5fc6f1df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 333 additions and 279 deletions

View File

@ -0,0 +1,103 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq, gt } from "truth-helpers";
import DButton from "discourse/components/d-button";
import BrowseMore from "discourse/components/more-topics/browse-more";
import concatClass from "discourse/helpers/concat-class";
import { applyValueTransformer } from "discourse/lib/transformer";
export let registeredTabs = [];
export function clearRegisteredTabs() {
registeredTabs.length = 0;
}
export default class MoreTopics extends Component {
@service currentUser;
@service keyValueStore;
@tracked selectedTab = this.initialTab;
get initialTab() {
let savedId = this.keyValueStore.get(
`more-topics-preference-${this.context}`
);
// Fallback to the old setting
savedId ||= this.keyValueStore.get("more-topics-list-preference");
return (
(savedId && this.tabs.find((tab) => tab.id === savedId)) || this.tabs[0]
);
}
get activeTab() {
return this.tabs.find((tab) => tab === this.selectedTab) || this.tabs[0];
}
get context() {
return this.args.topic.get("isPrivateMessage") ? "pm" : "topic";
}
@cached
get tabs() {
const defaultTabs = registeredTabs.filter((tab) =>
tab.condition({ topic: this.args.topic, context: this.context })
);
return applyValueTransformer("more-topics-tabs", defaultTabs, {
currentContext: this.context,
user: this.currentUser,
topic: this.args.topic,
});
}
@action
selectTab(tab) {
this.selectedTab = tab;
this.keyValueStore.set({
key: `more-topics-preference-${this.context}`,
value: tab.id,
});
}
<template>
<div class="more-topics__container">
{{#if (gt this.tabs.length 1)}}
<div class="row">
<ul class="nav nav-pills">
{{#each this.tabs as |tab|}}
<li>
<DButton
@action={{fn this.selectTab tab}}
@translatedLabel={{tab.name}}
@translatedTitle={{tab.name}}
@icon={{tab.icon}}
class={{if (eq tab.id this.activeTab.id) "active"}}
/>
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if this.activeTab}}
<div
class={{concatClass
"more-topics__lists"
(if (eq this.tabs.length 1) "single-list")
}}
>
<this.activeTab.component @topic={{@topic}} />
</div>
{{#if @topic.suggestedTopics.length}}
<BrowseMore @topic={{@topic}} />
{{/if}}
{{/if}}
</div>
</template>
}

View File

@ -1,49 +0,0 @@
<div class="more-topics__container">
{{#unless this.singleList}}
<div class="row">
<ul class="nav nav-pills">
{{#each this.availableTabs as |tab|}}
<li>
<DButton
@translatedTitle={{tab.name}}
@translatedLabel={{tab.name}}
@action={{fn this.rememberTopicListPreference tab.id}}
@icon={{tab.icon}}
class={{if (eq tab.id this.selectedTab) "active"}}
/>
</li>
{{/each}}
</ul>
</div>
{{/unless}}
<div class="more-topics__lists {{if this.singleList 'single-list'}}">
{{#if @topic.relatedMessages.length}}
<RelatedMessages @topic={{@topic}} />
{{/if}}
{{#if @topic.suggestedTopics.length}}
<SuggestedTopics @topic={{@topic}} />
<span>
<PluginOutlet
@name="below-suggested-topics"
@connectorTagName="div"
@outletArgs={{hash topic=@topic}}
/>
</span>
{{/if}}
<PluginOutlet
@name="topic-more-content"
@outletArgs={{hash model=@topic}}
/>
</div>
{{#if @topic.suggestedTopics.length}}
<h3 class="more-topics__browse-more">
{{html-safe this.browseMoreMessage}}
</h3>
{{/if}}
</div>

View File

@ -1,52 +1,25 @@
import Component from "@glimmer/component";
import { action, computed } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { categoryBadgeHTML } from "discourse/helpers/category-link";
import getURL from "discourse-common/lib/get-url";
import { iconHTML } from "discourse-common/lib/icon-library";
import I18n from "discourse-i18n";
export default class MoreTopics extends Component {
@service site;
@service moreTopicsPreferenceTracking;
@service pmTopicTrackingState;
@service topicTrackingState;
export default class BrowseMore extends Component {
@service currentUser;
@service pmTopicTrackingState;
@service site;
@service topicTrackingState;
@action
rememberTopicListPreference(value) {
this.moreTopicsPreferenceTracking.updatePreference(value);
groupLink(groupName) {
return `<a class="group-link" href="${getURL(
`/u/${this.currentUser.username}/messages/group/${groupName}`
)}">${iconHTML("users")} ${groupName}</a>`;
}
@computed("moreTopicsPreferenceTracking.topicLists")
get singleList() {
return this.availableTabs.length === 1;
}
@computed("moreTopicsPreferenceTracking.selectedTab")
get selectedTab() {
return this.moreTopicsPreferenceTracking.selectedTab;
}
@computed("moreTopicsPreferenceTracking.topicLists")
get availableTabs() {
return this.moreTopicsPreferenceTracking.topicLists;
}
@computed(
"pmTopicTrackingState.isTracking",
"pmTopicTrackingState.statesModificationCounter",
"topicTrackingState.messageCount"
)
get browseMoreMessage() {
return this.args.topic.isPrivateMessage
? this._privateMessageBrowseMoreMessage()
: this._topicBrowseMoreMessage();
}
_privateMessageBrowseMoreMessage() {
const username = this.currentUser.username;
const suggestedGroupName = this.args.topic.suggested_group_name;
get privateMessageBrowseMoreMessage() {
const suggestedGroupName = this.args.topic.get("suggested_group_name");
const inboxFilter = suggestedGroupName ? "group" : "user";
const unreadCount = this.pmTopicTrackingState.lookupCount("unread", {
@ -67,9 +40,9 @@ export default class MoreTopics extends Component {
HAS_UNREAD_AND_NEW: hasBoth,
UNREAD: unreadCount,
NEW: newCount,
username,
username: this.currentUser.username,
groupName: suggestedGroupName,
groupLink: this._groupLink(username, suggestedGroupName),
groupLink: this.groupLink(suggestedGroupName),
basePath: getURL(""),
});
} else {
@ -77,24 +50,24 @@ export default class MoreTopics extends Component {
HAS_UNREAD_AND_NEW: hasBoth,
UNREAD: unreadCount,
NEW: newCount,
username,
username: this.currentUser.username,
basePath: getURL(""),
});
}
} else if (suggestedGroupName) {
return I18n.t("user.messages.read_more_in_group", {
groupLink: this._groupLink(username, suggestedGroupName),
groupLink: this.groupLink(suggestedGroupName),
});
} else {
return I18n.t("user.messages.read_more", {
basePath: getURL(""),
username,
username: this.currentUser.username,
});
}
}
_topicBrowseMoreMessage() {
let category = this.args.topic.category;
get topicBrowseMoreMessage() {
let category = this.args.topic.get("category");
if (category && category.id === this.site.uncategorized_category_id) {
category = null;
@ -113,7 +86,7 @@ export default class MoreTopics extends Component {
HAS_UNREAD_AND_NEW: unreadTopics > 0 && newTopics > 0,
UNREAD: unreadTopics,
NEW: newTopics,
HAS_CATEGORY: category ? true : false,
HAS_CATEGORY: !!category,
categoryLink: category ? categoryBadgeHTML(category) : null,
basePath: getURL(""),
});
@ -130,9 +103,13 @@ export default class MoreTopics extends Component {
}
}
_groupLink(username, groupName) {
return `<a class="group-link" href="${getURL(
`/u/${username}/messages/group/${groupName}`
)}">${iconHTML("users")} ${groupName}</a>`;
}
<template>
<h3 class="more-topics__browse-more">
{{#if @topic.isPrivateMessage}}
{{htmlSafe this.privateMessageBrowseMoreMessage}}
{{else}}
{{htmlSafe this.topicBrowseMoreMessage}}
{{/if}}
</h3>
</template>
}

View File

@ -1,56 +1,33 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import BasicTopicList from "discourse/components/basic-topic-list";
import concatClass from "discourse/helpers/concat-class";
import i18n from "discourse-common/helpers/i18n";
import getURL from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
const LIST_ID = "related-Messages";
export default class RelatedMessages extends Component {
@service moreTopicsPreferenceTracking;
@service currentUser;
get hidden() {
return this.moreTopicsPreferenceTracking.get("selectedTab") !== LIST_ID;
}
@action
registerList() {
this.moreTopicsPreferenceTracking.registerTopicList({
name: I18n.t("related_messages.pill"),
id: LIST_ID,
});
}
@action
removeList() {
this.moreTopicsPreferenceTracking.removeTopicList(LIST_ID);
}
@cached
get targetUser() {
const topic = this.args.topic;
const { topic } = this.args;
if (!topic || !topic.isPrivateMessage) {
return;
}
const allowedUsers = topic.details.allowed_users;
if (
topic.relatedMessages?.length >= 5 &&
allowedUsers.length === 2 &&
topic.details.allowed_groups.length === 0 &&
allowedUsers.find((u) => u.username === this.currentUser.username)
topic.details.allowed_users.length === 2 &&
topic.details.allowed_users.find(
(u) => u.username === this.currentUser.username
)
) {
return allowedUsers.find((u) => u.username !== this.currentUser.username);
return topic.details.allowed_users.find(
(u) => u.username !== this.currentUser.username
);
}
}
@ -62,12 +39,10 @@ export default class RelatedMessages extends Component {
<template>
<div
id="related-messages"
class={{concatClass "more-topics__list" (if this.hidden "hidden")}}
role="complementary"
aria-labelledby="related-messages-title"
{{didInsert this.registerList}}
{{willDestroy this.removeList}}
id="related-messages"
class="more-topics__list"
>
<h3 id="related-messages-title" class="more-topics__list-title">
{{i18n "related_messages.title"}}
@ -75,9 +50,9 @@ export default class RelatedMessages extends Component {
<div class="topics">
<BasicTopicList
@hideCategory="true"
@showPosters="true"
@topics={{@topic.relatedMessages}}
@hideCategory={{true}}
@showPosters={{true}}
/>
</div>

View File

@ -0,0 +1,51 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import BasicTopicList from "discourse/components/basic-topic-list";
import UserTip from "discourse/components/user-tip";
import i18n from "discourse-common/helpers/i18n";
export default class SuggestedTopics extends Component {
@service currentUser;
get suggestedTitle() {
const href = this.currentUser?.pmPath(this.args.topic);
if (href && this.args.topic.isPrivateMessage) {
return i18n("suggested_topics.pm_title");
} else {
return i18n("suggested_topics.title");
}
}
<template>
<div
role="complementary"
aria-labelledby="suggested-topics-title"
id="suggested-topics"
class="more-topics__list"
>
<UserTip
@id="suggested_topics"
@titleText={{i18n "user_tips.suggested_topics.title"}}
@contentText={{i18n "user_tips.suggested_topics.content"}}
@placement="top-start"
@priority={{700}}
/>
<h3 id="suggested-topics-title" class="more-topics__list-title">
{{this.suggestedTitle}}
</h3>
<div class="topics">
{{#if @topic.isPrivateMessage}}
<BasicTopicList
@topics={{@topic.suggestedTopics}}
@hideCategory={{true}}
@showPosters={{true}}
/>
{{else}}
<BasicTopicList @topics={{@topic.suggestedTopics}} />
{{/if}}
</div>
</div>
</template>
}

View File

@ -1,34 +0,0 @@
<div
id="suggested-topics"
class="more-topics__list {{if this.hidden 'hidden'}}"
role="complementary"
aria-labelledby="suggested-topics-title"
{{did-insert this.registerList}}
{{will-destroy this.removeList}}
>
{{#unless this.hidden}}
<UserTip
@id="suggested_topics"
@titleText={{i18n "user_tips.suggested_topics.title"}}
@contentText={{i18n "user_tips.suggested_topics.content"}}
@placement="top-start"
@priority={{700}}
/>
{{/unless}}
<h3 id="suggested-topics-title" class="more-topics__list-title">
{{i18n this.suggestedTitleLabel}}
</h3>
<div class="topics">
{{#if @topic.isPrivateMessage}}
<BasicTopicList
@hideCategory="true"
@showPosters="true"
@topics={{@topic.suggestedTopics}}
/>
{{else}}
<BasicTopicList @topics={{@topic.suggestedTopics}} />
{{/if}}
</div>
</div>

View File

@ -1,38 +0,0 @@
import Component from "@glimmer/component";
import { action, computed } from "@ember/object";
import { service } from "@ember/service";
import I18n from "discourse-i18n";
export default class SuggestedTopics extends Component {
@service moreTopicsPreferenceTracking;
@service currentUser;
listId = "suggested-topics";
get suggestedTitleLabel() {
const href = this.currentUser && this.currentUser.pmPath(this.args.topic);
if (this.args.topic.isPrivateMessage && href) {
return "suggested_topics.pm_title";
} else {
return "suggested_topics.title";
}
}
@computed("moreTopicsPreferenceTracking.selectedTab")
get hidden() {
return this.moreTopicsPreferenceTracking.selectedTab !== this.listId;
}
@action
registerList() {
this.moreTopicsPreferenceTracking.registerTopicList({
name: I18n.t("suggested_topics.pill"),
id: this.listId,
});
}
@action
removeList() {
this.moreTopicsPreferenceTracking.removeTopicList(this.listId);
}
}

View File

@ -0,0 +1,25 @@
import RelatedMessages from "discourse/components/related-messages";
import SuggestedTopics from "discourse/components/suggested-topics";
import { withPluginApi } from "discourse/lib/plugin-api";
import i18n from "discourse-common/helpers/i18n";
export default {
initialize() {
withPluginApi("1.37.2", (api) => {
api.registerMoreTopicsTab({
id: "related-messages",
name: i18n("related_messages.pill"),
component: RelatedMessages,
condition: ({ context, topic }) =>
context === "pm" && topic.relatedMessages?.length > 0,
});
api.registerMoreTopicsTab({
id: "suggested-topics",
name: i18n("suggested_topics.pill"),
component: SuggestedTopics,
condition: ({ topic }) => topic.suggestedTopics?.length > 0,
});
});
},
};

View File

@ -3,7 +3,7 @@
// docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version
// using the format described at https://keepachangelog.com/en/1.0.0/.
export const PLUGIN_API_VERSION = "1.37.3";
export const PLUGIN_API_VERSION = "1.38.0";
import $ from "jquery";
import { h } from "virtual-dom";
@ -23,6 +23,7 @@ import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from
import { addGlobalNotice } from "discourse/components/global-notice";
import { headerButtonsDAG } from "discourse/components/header";
import { headerIconsDAG } from "discourse/components/header/icons";
import { registeredTabs } from "discourse/components/more-topics";
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import { addPluginOutletDecorator } from "discourse/components/plugin-connector";
import {
@ -3286,6 +3287,50 @@ class PluginApi {
registerPluginHeaderActionComponent(pluginId, componentClass);
}
/**
* Registers a new tab to be displayed in "more topics" area at the bottom of a topic page.
*
* ```gjs
* api.registerMoreTopicsTab({
* id: "other-topics",
* name: i18n("other_topics.tab"),
* component: <template>tbd</template>,
* condition: ({ topic }) => topic.otherTopics?.length > 0,
* });
* ```
*
* You can additionally use more-topics-tabs value transformer to conditionally show/hide
* specific tabs.
*
* ```js
* api.registerValueTransformer("more-topics-tabs", ({ value, context }) => {
* if (context.user?.aFeatureFlag) {
* // Remove "suggested" from the topics page
* return value.filter(
* (tab) =>
* context.currentContext !== "topic" ||
* tab.id !== "suggested-topics"
* );
* }
* });
* ```
*
* @callback tabCondition
* @param {Object} opts
* @param {"topic"|"pm"} opts.context - the type of the current page
* @param {Topic} opts.topic - the current topic
*
* @param {Object} tab
* @param {string} tab.id - an identifier used in more-topics-tabs value transformer
* @param {string} tab.name - a name displayed on the tab
* @param {string} tab.icon - an optional icon displayed on the tab
* @param {Class} tab.component - contents of the tab
* @param {tabCondition} tab.condition - an optional callback to conditionally show the tab
*/
registerMoreTopicsTab(tab) {
registeredTabs.push(tab);
}
// eslint-disable-next-line no-unused-vars
#deprecatedWidgetOverride(widgetName, override) {
// insert here the code to handle widget deprecations, e.g. for the header widgets we used:

View File

@ -7,8 +7,9 @@ export const VALUE_TRANSFORMERS = Object.freeze([
// use only lowercase names
"category-description-text",
"category-display-name",
"mentions-class",
"header-notifications-avatar-size",
"home-logo-href",
"home-logo-image-url",
"mentions-class",
"more-topics-tabs",
]);

View File

@ -1,4 +1,6 @@
import { cached } from "@glimmer/tracking";
import EmberObject, { computed } from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat";
import { alias, and, equal, notEmpty, or } from "@ember/object/computed";
import { service } from "@ember/service";
import { Promise } from "rsvp";
@ -430,18 +432,20 @@ export default class Topic extends RestModel {
return newTags;
}
@discourseComputed("related_messages")
relatedMessages(relatedMessages) {
if (relatedMessages) {
return relatedMessages.map((st) => this.store.createRecord("topic", st));
}
@dependentKeyCompat
@cached
get relatedMessages() {
return this.get("related_messages")?.map((st) =>
this.store.createRecord("topic", st)
);
}
@discourseComputed("suggested_topics")
suggestedTopics(suggestedTopics) {
if (suggestedTopics) {
return suggestedTopics.map((st) => this.store.createRecord("topic", st));
}
@dependentKeyCompat
@cached
get suggestedTopics() {
return this.get("suggested_topics")?.map((st) =>
this.store.createRecord("topic", st)
);
}
@discourseComputed("posts_count")

View File

@ -1,56 +0,0 @@
import { tracked } from "@glimmer/tracking";
import Service, { service } from "@ember/service";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
const TOPIC_LIST_PREFERENCE_KEY = "more-topics-list-preference";
@disableImplicitInjections
export default class MoreTopicsPreferenceTracking extends Service {
@service keyValueStore;
@tracked selectedTab = null;
@tracked topicLists = [];
memoryTab = null;
init() {
super.init(...arguments);
this.memoryTab = this.keyValueStore.get(TOPIC_LIST_PREFERENCE_KEY);
}
updatePreference(value) {
// Don't change the preference when selecting related PMs.
// It messes with the topics pref.
const rememberPref = value !== "related-messages";
if (rememberPref) {
this.keyValueStore.set({ key: TOPIC_LIST_PREFERENCE_KEY, value });
this.memoryTab = value;
}
this.selectedTab = value;
}
registerTopicList(item) {
// We have a preference stored and the list exists.
if (this.memoryTab && this.memoryTab === item.id) {
this.selectedTab = item.id;
}
// Use the first list as a default. Future lists may override this
// if they match the stored preference.
if (!this.selectedTab) {
this.selectedTab = item.id;
}
this.topicLists = [...this.topicLists, item];
}
removeTopicList(itemId) {
this.topicLists = this.topicLists.filter((item) => item.id !== itemId);
if (this.selectedTab === itemId) {
this.selectedTab = this.topicLists[0]?.id;
}
}
}

View File

@ -0,0 +1,45 @@
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { PLUGIN_API_VERSION, withPluginApi } from "discourse/lib/plugin-api";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("More topics - Plugin API", function (needs) {
needs.user();
test("displays the tabs", async function (assert) {
withPluginApi(PLUGIN_API_VERSION, (api) => {
api.registerMoreTopicsTab({
id: "my-tab",
name: "News",
component: <template>hello there!</template>,
condition: ({ context, topic }) =>
context === "topic" && topic.id === 280,
});
api.registerMoreTopicsTab({
id: "my-pm-tab",
name: "Other",
component: <template>hi!</template>,
condition: ({ context }) => context === "pm",
});
});
await visit("/t/-/280");
assert.dom(".more-topics__container li").exists({ count: 2 });
assert.dom(".more-topics__container li:last-of-type").hasText("News");
await click(`.more-topics__container button[title="News"]`);
assert.dom(".more-topics__lists").hasText("hello there!");
await visit("/t/-/12");
assert.dom(".more-topics__container li").exists({ count: 2 });
assert.dom(".more-topics__container li:last-of-type").hasText("Other");
await click(`.more-topics__container button[title="Other"]`);
assert.dom(".more-topics__lists").hasText("hi!");
await visit("/t/-/54077");
assert.dom(".more-topics__container li").doesNotExist();
assert.dom(".more-topics__container #suggested-topics-title").exists();
});
});

View File

@ -22,6 +22,7 @@ import {
import { clearToolbarCallbacks } from "discourse/components/d-editor";
import { clearExtraHeaderButtons as clearExtraGlimmerHeaderButtons } from "discourse/components/header";
import { clearExtraHeaderIcons as clearExtraGlimmerHeaderIcons } from "discourse/components/header/icons";
import { clearRegisteredTabs } from "discourse/components/more-topics";
import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
import { resetDecorators as resetPluginOutletDecorators } from "discourse/components/plugin-connector";
import { resetItemSelectCallbacks } from "discourse/components/search-menu/results/assistant-item";
@ -253,6 +254,7 @@ export function testCleanup(container, app) {
clearAboutPageActivities();
resetWidgetCleanCallbacks();
clearPluginHeaderActionComponents();
clearRegisteredTabs();
}
function cleanupCssGeneratorTags() {

View File

@ -7,11 +7,14 @@ in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.38.0] - 2024-10-30
- Added `registerMoreTopicsTab` and "more-topics-tabs" value transformer that allows to add or remove new tabs to the "more topics" (suggested/related) area.
## [1.37.3] - 2024-10-24
- Added `disableDefaultKeyboardShortcuts` which allows plugins/TCs to disable default keyboard shortcuts.
## [1.37.2] - 2024-10-02
- Fixed comments and text references to Font Awesome 5 in favor of the more generic Font Awesome due to core now having the latest version and no longer needing to specify version 5.