DEV: Adopt post list component and new posts route front-end (#30604)

Recently we introduced a new `PostList` component (d886c55f63). In this update, we make broader adoption of this component. In particular, these areas include using the new component in the user activity stream pages, user's deleted posts, and pending posts page. This update also takes the existing `posts` route and adds a barebones front-end for it to view posts all in one page.

---------

Co-authored-by: David Taylor <david@taylorhq.com>
This commit is contained in:
Keegan George 2025-01-24 03:20:45 +09:00 committed by GitHub
parent 2b63830496
commit 7b76d25946
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 815 additions and 467 deletions

View File

@ -1 +0,0 @@
<UserStreamItem @item={{@post}} />

View File

@ -1,30 +0,0 @@
import Component from "@ember/component";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
import { ajax } from "discourse/lib/ajax";
import { afterRender } from "discourse/lib/decorators";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
export default class PendingPost extends Component {
didRender() {
super.didRender(...arguments);
this._loadOneboxes();
this._resolveUrls();
}
@afterRender
_loadOneboxes() {
loadOneboxes(
this.element,
ajax,
this.post.topic_id,
this.post.category_id,
this.siteSettings.max_oneboxes_per_post,
true
);
}
@afterRender
_resolveUrls() {
resolveAllShortUrls(ajax, this.siteSettings, this.element, this.opts);
}
}

View File

@ -0,0 +1,21 @@
import Component from "@glimmer/component";
import { actionDescriptionHtml } from "discourse/widgets/post-small-action";
export default class PostActionDescription extends Component {
get description() {
if (this.args.actionCode && this.args.createdAt) {
return actionDescriptionHtml(
this.args.actionCode,
this.args.createdAt,
this.args.username,
this.args.path
);
}
}
<template>
{{#if this.description}}
<p class="excerpt">{{this.description}}</p>
{{/if}}
</template>
}

View File

@ -8,14 +8,21 @@
* @args {String} emptyText (optional) - Custom text to display when there are no posts
* @args {String|Array} additionalItemClasses (optional) - Additional classes to add to each post list item
* @args {String} titleAriaLabel (optional) - Custom Aria label for the post title
* @args {String} showUserInfo (optional) - Whether to show user info in the post list items
* @args {String} idPath (optional) - The path to the key in the post object that contains the post ID
* @args {String} urlPath (optional) - The path to the key in the post object that contains the post URL
* @args {String} titlePath (optional) - The path to the key in the post object that contains the post title
* @args {String} usernamePath (optional) - The path to the key in the post object that contains the post author's username
*
* @template Usage Example:
* ```
* <PostList
* @posts={{this.posts}}
* @titlePath="topic_html_title"
* @fetchMorePosts={{this.loadMorePosts}}
* @emptyText={{i18n "custom_identifier.empty"}}
* @additionalItemClasses="custom-class"
*
* />
* ```
*/
@ -68,13 +75,31 @@ export default class PostList extends Component {
{{/if}}
<LoadMore @selector=".post-list-item" @action={{this.loadMore}}>
<div class="post-list">
<div class="post-list" ...attributes>
{{#each @posts as |post|}}
<PostListItem
@post={{post}}
@idPath={{@idPath}}
@urlPath={{@urlPath}}
@titlePath={{@titlePath}}
@usernamePath={{@usernamePath}}
@additionalItemClasses={{@additionalItemClasses}}
@titleAriaLabel={{@titleAriaLabel}}
/>
@showUserInfo={{@showUserInfo}}
>
<:abovePostItemHeader>
{{yield post to="abovePostItemHeader"}}
</:abovePostItemHeader>
<:belowPostItemMetaData>
{{yield post to="belowPostItemMetaData"}}
</:belowPostItemMetaData>
<:abovePostItemExcerpt>
{{yield post to="abovePostItemExcerpt"}}
</:abovePostItemExcerpt>
<:belowPostItem>
{{yield post to="belowPostItem"}}
</:belowPostItem>
</PostListItem>
{{else}}
<div class="post-list__empty-text">{{this.emptyText}}</div>
{{/each}}

View File

@ -1,56 +1,84 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import PluginOutlet from "discourse/components/plugin-outlet";
import TopicStatus from "discourse/components/topic-status";
import categoryLink from "discourse/helpers/category-link";
import getURL from "discourse/lib/get-url";
import { prioritizeNameInUx } from "discourse/lib/settings";
import { i18n } from "discourse-i18n";
export default class PostListItemDetails extends Component {
get url() {
return this.args.urlPath
? this.args.post[this.args.urlPath]
: this.args.post.url;
}
get showUserInfo() {
if (this.args.showUserInfo !== undefined) {
return this.args.showUserInfo && this.args.user;
}
return this.args.user;
}
get topicTitle() {
return this.args.titlePath
? this.args.post[this.args.titlePath]
: this.args.post.title;
}
get titleAriaLabel() {
return (
this.args.titleAriaLabel ||
i18n("post_list.aria_post_number", {
title: this.args.post.title,
if (this.args.titleAriaLabel) {
return this.args.titleAriaLabel;
}
if (this.args.post.post_number && this.topicTitle) {
return i18n("post_list.aria_post_number", {
title: this.topicTitle,
postNumber: this.args.post.post_number,
})
);
});
}
}
get posterName() {
if (prioritizeNameInUx(this.args.post.user.name)) {
return this.args.post.user.name;
if (prioritizeNameInUx(this.args.user.name)) {
return this.args.user.name;
}
return this.args.post.user.username;
return this.args.user.username;
}
<template>
<div class="post-list-item__details">
<div class="stream-topic-title">
<TopicStatus @topic={{@post}} @disableActions={{true}} />
<span class="title">
{{#if this.url}}
<a
href={{getURL @post.url}}
href={{getURL this.url}}
aria-label={{this.titleAriaLabel}}
>{{htmlSafe @post.topic.fancyTitle}}</a>
>{{this.topicTitle}}</a>
{{else}}
{{this.topicTitle}}
{{/if}}
</span>
</div>
<div class="stream-post-category">
<div class="category stream-post-category">
{{categoryLink @post.category}}
</div>
{{#if @post.user}}
{{#if this.showUserInfo}}
<div class="post-member-info names">
<span class="name">{{this.posterName}}</span>
{{#if @post.user.title}}
<span class="user-title">{{@post.user.title}}</span>
{{#if @user.title}}
<span class="user-title">{{@user.user_title}}</span>
{{/if}}
<PluginOutlet
@name="post-list-additional-member-info"
@outletArgs={{hash user=@post.user}}
@outletArgs={{hash user=@user}}
/>
{{!
@ -59,7 +87,7 @@ export default class PostListItemDetails extends Component {
}}
<PluginOutlet
@name="group-post-additional-member-info"
@outletArgs={{hash user=@post.user}}
@outletArgs={{hash user=@user}}
/>
</div>
{{/if}}

View File

@ -5,11 +5,14 @@ import ExpandPost from "discourse/components/expand-post";
import PostListItemDetails from "discourse/components/post-list/item/details";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse/helpers/d-icon";
import formatDate from "discourse/helpers/format-date";
import { userPath } from "discourse/lib/url";
export default class PostListItem extends Component {
@service site;
@service siteSettings;
@service currentUser;
get moderatorActionClass() {
return this.args.post.post_type === this.site.post_types.moderator_action
@ -23,44 +26,114 @@ export default class PostListItem extends Component {
}
}
get hiddenClass() {
return this.args.post.hidden && !this.currentUser?.staff;
}
get deletedClass() {
return this.args.post.deleted ? "deleted" : "";
}
get user() {
return {
id: this.args.post.user_id,
name: this.args.post.name,
username: this.args.usernamePath
? this.args.post[this.args.usernamePath]
: this.args.post.username,
avatar_template: this.args.post.avatar_template,
title: this.args.post.user_title,
primary_group_name: this.args.post.primary_group_name,
};
}
get postId() {
return this.args.idPath
? this.args.post[this.args.idPath]
: this.args.post.id;
}
<template>
<div
class="post-list-item
{{concatClass
this.moderatorActionClass
this.primaryGroupClass
this.hiddenClass
@additionalItemClasses
}}"
>
{{yield to="abovePostItemHeader"}}
<div class="post-list-item__header info">
<a
href={{userPath @post.user.username}}
data-user-card={{@post.user.username}}
href={{userPath this.user.username}}
data-user-card={{this.user.username}}
class="avatar-link"
>
<div class="avatar-wrapper">
{{avatar
@post.user
this.user
imageSize="large"
extraClasses="actor"
ignoreTitle="true"
}}
</div>
</a>
<PostListItemDetails
@post={{@post}}
@titleAriaLabel={{@titleAriaLabel}}
@titlePath={{@titlePath}}
@urlPath={{@urlPath}}
@user={{this.user}}
@showUserInfo={{@showUserInfo}}
/>
<ExpandPost @item={{@post}} />
<div class="time">{{formatDate @post.created_at leaveAgo="true"}}</div>
</div>
<div class="excerpt">
{{#if @post.expandedExcerpt}}
{{htmlSafe @post.expandedExcerpt}}
{{#if @post.draftType}}
<span class="draft-type">{{@post.draftType}}</span>
{{else}}
{{htmlSafe @post.excerpt}}
<ExpandPost @item={{@post}} />
{{/if}}
<div class="post-list-item__metadata">
<span class="time">
{{formatDate @post.created_at leaveAgo="true"}}
</span>
{{#if @post.deleted_by}}
<span class="delete-info">
{{dIcon "trash-can"}}
{{avatar
@post.deleted_by
imageSize="tiny"
extraClasses="actor"
ignoreTitle="true"
}}
{{formatDate @item.deleted_at leaveAgo="true"}}
</span>
{{/if}}
</div>
{{yield to="belowPostItemMetadata"}}
</div>
{{yield to="abovePostItemExcerpt"}}
<div
data-topic-id={{@post.topic_id}}
data-post-id={{this.postId}}
data-user-id={{@post.user_id}}
class="excerpt"
>
{{#if @post.expandedExcerpt}}
{{~htmlSafe @post.expandedExcerpt~}}
{{else}}
{{~htmlSafe @post.excerpt~}}
{{/if}}
</div>
{{yield to="belowPostItem"}}
</div>
</template>
}

View File

@ -3,6 +3,7 @@ import { computed } from "@ember/object";
import { classNameBindings, tagName } from "@ember-decorators/component";
import { propertyEqual } from "discourse/lib/computed";
import discourseComputed from "discourse/lib/decorators";
import deprecated from "discourse/lib/deprecated";
import { userPath } from "discourse/lib/url";
import { actionDescription } from "discourse/widgets/post-small-action";
@ -26,6 +27,18 @@ export default class UserStreamItem extends Component {
)
actionDescription;
constructor() {
super(...arguments);
deprecated(
`<UserStreamItem /> component is deprecated. Use <PostList /> or <UserStream /> component to render a post list instead.`,
{
since: "3.4.0.beta4",
dropFrom: "3.5.0.beta1",
id: "discourse.user-stream-item",
}
);
}
@computed("item.hidden")
get hidden() {
return (

View File

@ -0,0 +1,248 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import $ from "jquery";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import PostActionDescription from "discourse/components/post-action-description";
import PostList from "discourse/components/post-list";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse/helpers/d-icon";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ClickTrack from "discourse/lib/click-track";
import DiscourseURL from "discourse/lib/url";
import { NEW_TOPIC_KEY } from "discourse/models/composer";
import Draft from "discourse/models/draft";
import Post from "discourse/models/post";
import { i18n } from "discourse-i18n";
export default class UserStreamComponent extends Component {
@service dialog;
@service composer;
@service appEvents;
@service currentUser;
@service router;
@tracked lastDecoratedElement;
eventListeners = modifier((element) => {
$(element).on("click.details-disabled", "details.disabled", () => false);
$(element).on("click.discourse-redirect", ".excerpt a", (e) => {
return ClickTrack.trackClick(e, getOwner(this));
});
later(() => {
this.updateLastDecoratedElement();
this.appEvents.trigger("decorate-non-stream-cooked-element", element);
});
return () => {
$(element).off("click.details-disabled", "details.disabled");
// Unbind link tracking
$(element).off("click.discourse-redirect", ".excerpt a");
};
});
constructor() {
super(...arguments);
}
get filterClassName() {
const filter = this.args.stream?.filter;
if (filter) {
return `filter-${filter.toString().replace(",", "-")}`;
}
}
get usernamePath() {
// We want the draft_username for the drafts route,
// in-case you are editing a post that was created by another user
// the draft usernmae will show the post item to show the editing user
if (this.router.currentRouteName === "userActivity.drafts") {
return "draft_username";
}
return "username";
}
@action
updateLastDecoratedElement() {
const nodes = document.querySelectorAll(".user-stream-item");
if (!nodes || nodes.length === 0) {
return;
}
const lastElement = nodes[nodes.length - 1];
if (lastElement === this.lastDecoratedElement) {
return;
}
this.lastDecoratedElement = lastElement;
}
@action
async removeBookmark(userAction) {
try {
await Post.updateBookmark(userAction.get("post_id"), false);
this.args.stream.remove(userAction);
} catch (error) {
popupAjaxError(error);
}
}
@action
async resumeDraft(item) {
if (this.composer.get("model.viewOpen")) {
this.composer.close();
}
if (item.get("postUrl")) {
DiscourseURL.routeTo(item.get("postUrl"));
} else {
try {
const draftData = await Draft.get(item.draft_key);
const draft = draftData.draft || item.data;
if (!draft) {
return;
}
this.composer.open({
draft,
draftKey: item.draft_key,
draftSequence: draftData.draft_sequence,
});
} catch (error) {
popupAjaxError(error);
}
}
}
@action
removeDraft(draft) {
this.dialog.yesNoConfirm({
message: i18n("drafts.remove_confirmation"),
didConfirm: async () => {
try {
await Draft.clear(draft.draft_key, draft.sequence);
this.args.stream.remove(draft);
if (draft.draft_key === NEW_TOPIC_KEY) {
this.currentUser.has_topic_draft = false;
}
} catch (error) {
popupAjaxError(error);
}
},
});
}
@action
async loadMore() {
await this.args.stream.findItems();
if (this.args.stream.canLoadMore === false) {
return [];
}
later(() => {
let element = this.lastDecoratedElement?.nextElementSibling;
while (element) {
this.appEvents.trigger("user-stream:new-item-inserted", element);
this.appEvents.trigger("decorate-non-stream-cooked-element", element);
element = element.nextElementSibling;
}
this.updateLastDecoratedElement();
});
return this.args.stream.content;
}
<template>
<PostList
@posts={{@stream.content}}
@idPath="post_id"
@urlPath="postUrl"
@usernamePath={{this.usernamePath}}
@fetchMorePosts={{this.loadMore}}
@titlePath="titleHtml"
@additionalItemClasses="user-stream-item"
@showUserInfo={{false}}
class={{concatClass "user-stream" this.filterClassName}}
{{this.eventListeners @stream}}
>
<:abovePostItemHeader as |post|>
<PluginOutlet
@name="user-stream-item-above"
@outletArgs={{hash item=post}}
/>
</:abovePostItemHeader>
<:belowPostItemMetaData as |post|>
<span>
<PluginOutlet
@name="user-stream-item-header"
@connectorTagName="div"
@outletArgs={{hash item=post}}
/>
</span>
</:belowPostItemMetaData>
<:abovePostItemExcerpt as |post|>
<PostActionDescription
@actionCode={{post.action_code}}
@createdAt={{post.created_at}}
@username={{post.action_code_who}}
@path={{post.action_code_path}}
/>
{{#each post.children as |child|}}
<div class="user-stream-item-actions">
{{dIcon child.icon class="icon"}}
{{#each child.items as |grandChild|}}
<a
href={{grandChild.userUrl}}
data-user-card={{grandChild.username}}
class="avatar-link"
>
<div class="avatar-wrapper">
{{avatar
grandChild
imageSize="tiny"
extraClasses="actor"
ignoreTitle="true"
avatarTemplatePath="acting_avatar_template"
}}
</div>
</a>
{{#if grandChild.edit_reason}}
&mdash;
<span class="edit-reason">{{grandChild.edit_reason}}</span>
{{/if}}
{{/each}}
</div>
{{/each}}
{{#if post.editableDraft}}
<div class="user-stream-item-draft-actions">
<DButton
@action={{fn this.resumeDraft post}}
@icon="pencil"
@label="drafts.resume"
class="btn-default resume-draft"
/>
<DButton
@action={{fn this.removeDraft post}}
@icon="trash-can"
@title="drafts.remove"
class="btn-danger remove-draft"
/>
</div>
{{/if}}
</:abovePostItemExcerpt>
<:belowPostItem as |post|>
{{yield post to="bottom"}}
</:belowPostItem>
</PostList>
</template>
}

View File

@ -1,8 +0,0 @@
{{#each @stream.content as |item|}}
<UserStreamItem
@item={{item}}
@removeBookmark={{action "removeBookmark"}}
@resumeDraft={{action "resumeDraft"}}
@removeDraft={{action "removeDraft"}}
/>
{{/each}}

View File

@ -1,156 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { later } from "@ember/runloop";
import { service } from "@ember/service";
import { classNames, tagName } from "@ember-decorators/component";
import { on } from "@ember-decorators/object";
import $ from "jquery";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ClickTrack from "discourse/lib/click-track";
import DiscourseURL from "discourse/lib/url";
import LoadMore from "discourse/mixins/load-more";
import { NEW_TOPIC_KEY } from "discourse/models/composer";
import Draft from "discourse/models/draft";
import Post from "discourse/models/post";
import { i18n } from "discourse-i18n";
@tagName("ul")
@classNames("user-stream")
export default class UserStream extends Component.extend(LoadMore) {
@service dialog;
@service composer;
loading = false;
eyelineSelector = ".user-stream .item";
_lastDecoratedElement = null;
@on("init")
_initialize() {
const filter = this.get("stream.filter");
if (filter) {
this.set("classNames", [
"user-stream",
"filter-" + filter.toString().replace(",", "-"),
]);
}
}
@on("didInsertElement")
_inserted() {
$(this.element).on(
"click.details-disabled",
"details.disabled",
() => false
);
$(this.element).on("click.discourse-redirect", ".excerpt a", (e) => {
return ClickTrack.trackClick(e, getOwner(this));
});
this._updateLastDecoratedElement();
this.appEvents.trigger("decorate-non-stream-cooked-element", this.element);
}
// This view is being removed. Shut down operations
@on("willDestroyElement")
_destroyed() {
$(this.element).off("click.details-disabled", "details.disabled");
// Unbind link tracking
$(this.element).off("click.discourse-redirect", ".excerpt a");
}
_updateLastDecoratedElement() {
const nodes = this.element.querySelectorAll(".user-stream-item");
if (nodes.length === 0) {
return;
}
const lastElement = nodes[nodes.length - 1];
if (lastElement === this._lastDecoratedElement) {
return;
}
this._lastDecoratedElement = lastElement;
}
@action
removeBookmark(userAction) {
const stream = this.stream;
Post.updateBookmark(userAction.get("post_id"), false)
.then(() => {
stream.remove(userAction);
})
.catch(popupAjaxError);
}
@action
resumeDraft(item) {
if (this.composer.get("model.viewOpen")) {
this.composer.close();
}
if (item.get("postUrl")) {
DiscourseURL.routeTo(item.get("postUrl"));
} else {
Draft.get(item.draft_key)
.then((d) => {
const draft = d.draft || item.data;
if (!draft) {
return;
}
this.composer.open({
draft,
draftKey: item.draft_key,
draftSequence: d.draft_sequence,
});
})
.catch((error) => {
popupAjaxError(error);
});
}
}
@action
removeDraft(draft) {
const stream = this.stream;
this.dialog.yesNoConfirm({
message: i18n("drafts.remove_confirmation"),
didConfirm: () => {
Draft.clear(draft.draft_key, draft.sequence)
.then(() => {
stream.remove(draft);
if (draft.draft_key === NEW_TOPIC_KEY) {
this.currentUser.set("has_topic_draft", false);
}
})
.catch((error) => {
popupAjaxError(error);
});
},
});
}
@action
loadMore() {
if (this.loading) {
return;
}
this.set("loading", true);
const stream = this.stream;
stream.findItems().then(() => {
this.set("loading", false);
// The next elements are not rendered on the page yet, we need to
// wait for that before trying to decorate them.
later(() => {
let element = this._lastDecoratedElement?.nextElementSibling;
while (element) {
this.trigger("user-stream:new-item-inserted", element);
this.appEvents.trigger("decorate-non-stream-cooked-element", element);
element = element.nextElementSibling;
}
this._updateLastDecoratedElement();
});
});
}
}

View File

@ -4,6 +4,7 @@ import { dependentKeyCompat } from "@ember/object/compat";
import { and, equal, not, or, reads } from "@ember/object/computed";
import { next, throttle } from "@ember/runloop";
import { service } from "@ember/service";
import { isHTMLSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { observes, on } from "@ember-decorators/object";
import { Promise } from "rsvp";
@ -643,6 +644,9 @@ export default class Composer extends RestModel {
@discourseComputed("title")
titleLength(title) {
title = title || "";
if (isHTMLSafe(title)) {
return title.toString().length;
}
return title.replace(/\s+/gim, " ").trim().length;
}

View File

@ -0,0 +1,26 @@
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax";
import Category from "discourse/models/category";
import Post from "discourse/models/post";
import RestModel from "discourse/models/rest";
export default class Posts extends RestModel {
static async find(opts = {}) {
const data = {};
if (opts.before) {
data.before = opts.before;
}
if (opts.id) {
data.id = opts.id;
}
const { latest_posts } = await ajax("/posts.json", { data });
return latest_posts.map((post) => {
post.category = Category.findById(post.category_id);
post.topic_html_title = htmlSafe(post.topic_html_title);
return Post.create(post);
});
}
}

View File

@ -1,4 +1,5 @@
import { service } from "@ember/service";
import replaceEmoji from "discourse/helpers/replace-emoji";
import discourseComputed from "discourse/lib/decorators";
import { userPath } from "discourse/lib/url";
import { postUrl } from "discourse/lib/utilities";
@ -12,6 +13,10 @@ import { i18n } from "discourse-i18n";
export default class UserDraft extends RestModel {
@service currentUser;
get titleHtml() {
return replaceEmoji(this.get("title"));
}
@discourseComputed("draft_username")
editableDraft(draftUsername) {
return draftUsername === this.currentUser?.get("username");

View File

@ -1,8 +1,7 @@
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse/lib/decorators";
import { cook, emojiUnescape, excerpt } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import { cook, excerpt } from "discourse/lib/text";
import Category from "discourse/models/category";
import {
NEW_PRIVATE_MESSAGE_KEY,
@ -82,7 +81,6 @@ export default class UserDraftsStream extends RestModel {
) {
draft.title = draft.data.title;
}
draft.title = emojiUnescape(escapeExpression(draft.title));
if (draft.data.categoryId) {
draft.category = Category.findById(draft.data.categoryId) || null;
}

View File

@ -1,10 +1,9 @@
import { A } from "@ember/array";
import { Promise } from "rsvp";
import replaceEmoji from "discourse/helpers/replace-emoji";
import { ajax } from "discourse/lib/ajax";
import { url } from "discourse/lib/computed";
import discourseComputed from "discourse/lib/decorators";
import { emojiUnescape } from "discourse/lib/text";
import { escapeExpression } from "discourse/lib/utilities";
import RestModel from "discourse/models/rest";
import Site from "discourse/models/site";
import UserAction from "discourse/models/user-action";
@ -116,8 +115,8 @@ export default class UserStream extends RestModel {
Site.current().updateCategory(category);
});
result.user_actions.forEach((action) => {
action.title = emojiUnescape(escapeExpression(action.title));
result.user_actions?.forEach((action) => {
action.titleHtml = replaceEmoji(action.title);
copy.pushObject(UserAction.create(action));
});

View File

@ -9,6 +9,7 @@ export default function () {
this.route("about", { resetNamespace: true });
this.route("post", { path: "/p/:id" });
this.route("posts");
// Topic routes
this.route(

View File

@ -0,0 +1,11 @@
import { service } from "@ember/service";
import Posts from "discourse/models/posts";
import DiscourseRoute from "discourse/routes/discourse";
export default class PostsRoute extends DiscourseRoute {
@service router;
async model() {
return Posts.find();
}
}

View File

@ -1,5 +1,6 @@
<PostList
@posts={{this.model}}
@titlePath="topic_html_title"
@fetchMorePosts={{this.fetchMorePosts}}
@emptyText={{i18n "groups.empty.posts"}}
/>

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import RouteTemplate from "ember-route-template";
import PostList from "discourse/components/post-list";
import Posts from "discourse/models/posts";
export default RouteTemplate(
class extends Component {
@action
async loadMorePosts() {
const posts = this.args.model;
const before = posts[posts.length - 1].created_at;
return Posts.find({ before });
}
<template>
<PostList
@posts={{@model}}
@fetchMorePosts={{this.loadMorePosts}}
@titlePath="topic_html_title"
/>
</template>
}
);

View File

@ -1,5 +1,9 @@
<ul class="user-stream">
{{#each this.model as |pending_post|}}
<PendingPost @post={{pending_post}} />
{{/each}}
<PostList
@posts={{this.model}}
@urlPath="postUrl"
@showUserInfo={{false}}
@additionalItemClasses="user-stream-item"
class="user-stream"
/>
</ul>

View File

@ -1,5 +1 @@
{{#if this.model.canLoadMore}}
{{hide-application-footer}}
{{/if}}
<UserStream @stream={{this.model}} />

View File

@ -1,11 +1,8 @@
{{#if (or this.loading this.model.stream.canLoadMore)}}
{{hide-application-footer}}
{{/if}}
{{#if this.model.stream.noContent}}
<EmptyState
@title={{this.model.emptyState.title}}
@body={{this.model.emptyState.body}}
/>
{{/if}}
<UserStream @stream={{this.model.stream}} />

View File

@ -22,15 +22,15 @@ acceptance("User Anonymous", function () {
.dom(document.body)
.hasClass("user-activity-page", "has the body class");
assert.dom(".user-main .about").exists("has the about section");
assert.dom(".user-stream .item").exists("has stream items");
assert.dom(".user-stream-item").exists("has stream items");
await visit("/u/eviltrout/activity/topics");
assert.dom(".user-stream .item").doesNotExist("has no stream displayed");
assert.dom(".user-stream-item").doesNotExist("has no stream displayed");
assert.dom(".topic-list tr").exists("has a topic list");
await visit("/u/eviltrout/activity/replies");
assert.dom(".user-main .about").exists("has the about section");
assert.dom(".user-stream .item").exists("has stream items");
assert.dom(".user-stream-item").exists("has stream items");
assert.dom(".user-stream.filter-5").exists("stream has filter class");
});

View File

@ -306,14 +306,11 @@ export default {
name_lower: "ux",
auto_close_based_on_last_post: false,
},
user: {
id: 2770,
user_id: 2770,
username: "awesomerobot",
uploaded_avatar_id: 33872,
avatar_template:
"/user_avatar/meta.discourse.org/awesomerobot/{size}/33872.png",
},
},
{
id: 94603,
cooked:
@ -358,14 +355,11 @@ export default {
name_lower: "ux",
auto_close_based_on_last_post: false,
},
user: {
id: 2770,
user_id: 2770,
username: "awesomerobot",
uploaded_avatar_id: 33872,
avatar_template:
"/user_avatar/meta.discourse.org/awesomerobot/{size}/33872.png",
},
},
{
id: 94601,
cooked:
@ -410,13 +404,11 @@ export default {
name_lower: "ux",
auto_close_based_on_last_post: false,
},
user: {
id: 2770,
user_id: 2770,
username: "awesomerobot",
uploaded_avatar_id: 33872,
avatar_template:
"/user_avatar/meta.discourse.org/awesomerobot/{size}/33872.png",
},
},
{
id: 94577,
@ -463,14 +455,11 @@ export default {
name_lower: "feature",
auto_close_based_on_last_post: false,
},
user: {
id: 1995,
user_id: 1995,
username: "zogstrip",
uploaded_avatar_id: 8630,
avatar_template:
"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png",
},
},
{
id: 94574,
cooked:
@ -516,13 +505,11 @@ export default {
name_lower: "feature",
auto_close_based_on_last_post: false,
},
user: {
id: 1995,
user_id: 1995,
username: "zogstrip",
uploaded_avatar_id: 8630,
avatar_template:
"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png",
},
},
{
id: 94572,
@ -569,13 +556,11 @@ export default {
name_lower: "translations",
auto_close_based_on_last_post: false,
},
user: {
id: 1995,
user_id: 1995,
username: "zogstrip",
uploaded_avatar_id: 8630,
avatar_template:
"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png",
},
},
{
id: 94555,
@ -623,13 +608,11 @@ export default {
name_lower: "dev",
auto_close_based_on_last_post: false,
},
user: {
id: 1995,
user_id: 1995,
username: "zogstrip",
uploaded_avatar_id: 8630,
avatar_template:
"/user_avatar/meta.discourse.org/zogstrip/{size}/8630.png",
},
},
{
id: 94544,
@ -676,13 +659,9 @@ export default {
name_lower: "ux",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94543,
@ -729,13 +708,10 @@ export default {
name_lower: "feature",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94542,
@ -782,13 +758,9 @@ export default {
name_lower: "support",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94522,
@ -835,13 +807,9 @@ export default {
name_lower: "bug",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94521,
@ -888,13 +856,9 @@ export default {
name_lower: "ux",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94519,
@ -941,13 +905,9 @@ export default {
name_lower: "meta",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94518,
@ -994,13 +954,9 @@ export default {
name_lower: "support",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94517,
@ -1047,13 +1003,9 @@ export default {
name_lower: "bug",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94516,
@ -1100,13 +1052,9 @@ export default {
name_lower: "support",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94515,
@ -1154,13 +1102,9 @@ export default {
name_lower: "dev",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94514,
@ -1207,13 +1151,9 @@ export default {
name_lower: "extensibility",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94512,
@ -1260,13 +1200,9 @@ export default {
name_lower: "support",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
{
id: 94511,
@ -1313,13 +1249,9 @@ export default {
name_lower: "feature",
auto_close_based_on_last_post: false,
},
user: {
id: 32,
user_id: 32,
username: "codinghorror",
uploaded_avatar_id: 5297,
avatar_template:
"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
avatar_template: "/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png",
},
],
},

View File

@ -2,6 +2,8 @@ const postModel = [
{
id: 1,
title: "My dog is so cute",
url: "/t/my-dog-is-so-cute/1/1",
topic_id: 1,
created_at: "2024-03-15T18:45:38.720Z",
category: {
id: 1,
@ -21,6 +23,8 @@ const postModel = [
{
id: 2,
title: "My cat is adorable",
url: "/t/my-cat-is-so-adorable/2/1",
topic_id: 2,
created_at: "2024-03-16T18:45:38.720Z",
category: {
id: 1,

View File

@ -1,26 +0,0 @@
import { getOwner } from "@ember/owner";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | pending-post", function (hooks) {
setupRenderingTest(hooks);
test("it renders", async function (assert) {
const store = getOwner(this).lookup("service:store");
store.createRecord("category", { id: 2 });
const post = store.createRecord("pending-post", {
id: 1,
topic_url: "topic-url",
username: "USERNAME",
category_id: 2,
raw_text: "**bold text**",
});
this.set("post", post);
await render(hbs`<PendingPost @post={{this.post}}/>`);
assert.dom("p.excerpt").hasText("bold text", "renders the cooked text");
});
});

View File

@ -43,4 +43,61 @@ module("Integration | Component | PostList | Index", function (hooks) {
</template>);
assert.dom(".post-list__empty-text").hasText("My custom empty text");
});
test("@showUserInfo", async function (assert) {
const posts = postModel;
await render(<template>
<PostList @posts={{posts}} @showUserInfo={{false}} />
</template>);
assert.dom(".post-list-item__details .post-member-info").doesNotExist();
});
test("@titlePath", async function (assert) {
const posts = postModel.map((post) => {
post.topic_html_title = `Fancy title`;
return post;
});
await render(<template>
<PostList @posts={{posts}} @titlePath="topic_html_title" />
</template>);
assert.dom(".post-list-item__details .title a").hasText("Fancy title");
});
test("@idPath", async function (assert) {
const posts = postModel.map((post) => {
post.post_id = post.id;
return post;
});
await render(<template>
<PostList @posts={{posts}} @idPath="post_id" />
</template>);
assert.dom(".post-list-item .excerpt").hasAttribute("data-post-id", "1");
});
test("@urlPath", async function (assert) {
const posts = postModel.map((post) => {
post.postUrl = `/t/${post.topic_id}/${post.id}`;
return post;
});
await render(<template>
<PostList @posts={{posts}} @urlPath="postUrl" />
</template>);
assert
.dom(".post-list-item__details .title a")
.hasAttribute("href", "/t/1/1");
});
test("@usernamePath", async function (assert) {
const posts = postModel.map((post) => {
post.draft_username = "john";
return post;
});
await render(<template>
<PostList @posts={{posts}} @usernamePath="draft_username" />
</template>);
assert
.dom(".post-list-item__header .avatar-link")
.hasAttribute("data-user-card", "john");
});
});

View File

@ -62,7 +62,8 @@
line-height: var(--line-height-small);
color: var(--primary-medium);
font-size: var(--font-down-2);
padding-top: 5px;
padding-top: 6px;
margin-right: 0.5rem;
}
.delete-info {

View File

@ -25,7 +25,8 @@ class PostsController < ApplicationController
skip_before_action :preload_json,
:check_xhr,
only: %i[markdown_id markdown_num short_link latest user_posts_feed]
only: %i[markdown_id markdown_num short_link user_posts_feed]
skip_before_action :preload_json, :check_xhr, if: -> { request.format.rss? }
MARKDOWN_TOPIC_PAGE_SIZE = 100
@ -128,6 +129,7 @@ class PostsController < ApplicationController
scope: guardian,
root: params[:id],
add_raw: true,
add_excerpt: true,
add_title: true,
all_post_actions: counts,
),

View File

@ -5,15 +5,43 @@ require_relative "post_item_excerpt"
class GroupPostSerializer < ApplicationSerializer
include PostItemExcerpt
attributes :id, :created_at, :title, :url, :category_id, :post_number, :topic_id, :post_type
attributes :id,
:created_at,
:topic_id,
:topic_title,
:topic_slug,
:topic_html_title,
:url,
:category_id,
:post_number,
:posts_count,
:post_type,
:username,
:name,
:avatar_template,
:user_title,
:primary_group_name
# TODO(keegan): Remove `embed: :object` after updating references in discourse-reactions
has_one :user, serializer: GroupPostUserSerializer, embed: :object
has_one :topic, serializer: BasicTopicSerializer, embed: :object
def title
def topic_title
object.topic.title
end
def topic_html_title
object.topic.fancy_title
end
def topic_slug
object.topic.slug
end
def posts_count
object.topic.posts_count
end
def include_user_long_name?
SiteSetting.enable_names?
end
@ -21,4 +49,24 @@ class GroupPostSerializer < ApplicationSerializer
def category_id
object.topic.category_id
end
def username
object&.user&.username
end
def name
object&.user&.name
end
def avatar_template
object&.user&.avatar_template
end
def user_title
object&.user&.title
end
def primary_group_name
object&.user&.primary_group&.name
end
end

View File

@ -17,6 +17,7 @@ class PostSerializer < BasicPostSerializer
attributes :post_number,
:post_type,
:posts_count,
:updated_at,
:reply_count,
:reply_to_post_number,
@ -84,12 +85,14 @@ class PostSerializer < BasicPostSerializer
:last_wiki_edit,
:locked,
:excerpt,
:truncated,
:reviewable_id,
:reviewable_score_count,
:reviewable_score_pending_count,
:user_suspended,
:user_status,
:mentioned_users
:mentioned_users,
:post_url
def initialize(object, opts)
super(object, opts)
@ -99,6 +102,10 @@ class PostSerializer < BasicPostSerializer
end
end
def post_url
object&.url
end
def topic_slug
topic&.slug
end
@ -119,6 +126,14 @@ class PostSerializer < BasicPostSerializer
@add_excerpt
end
def include_truncated?
@add_excerpt
end
def truncated
true
end
def topic_title
topic&.title
end
@ -127,6 +142,10 @@ class PostSerializer < BasicPostSerializer
topic&.fancy_title
end
def posts_count
topic&.posts_count
end
def category_id
topic&.category_id
end

View File

@ -32,6 +32,9 @@
"post_type": {
"type": "integer"
},
"posts_count": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
@ -155,8 +158,7 @@
},
"actions_summary": {
"type": "array",
"items":
{
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
@ -223,6 +225,9 @@
},
"reviewable_score_pending_count": {
"type": "integer"
},
"post_url": {
"type": "string"
}
},
"required": [
@ -234,6 +239,7 @@
"cooked",
"post_number",
"post_type",
"posts_count",
"updated_at",
"reply_count",
"reply_to_post_number",
@ -274,7 +280,8 @@
"wiki",
"reviewable_id",
"reviewable_score_count",
"reviewable_score_pending_count"
"reviewable_score_pending_count",
"post_url"
]
}
}

View File

@ -22,6 +22,9 @@
"post_type": {
"type": "integer"
},
"posts_count": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
@ -203,6 +206,9 @@
"reviewable_score_pending_count": {
"type": "integer"
},
"post_url": {
"type": "string"
},
"mentioned_users": {
"type": "array",
"items": {}
@ -228,6 +234,7 @@
"cooked",
"post_number",
"post_type",
"posts_count",
"updated_at",
"reply_count",
"reply_to_post_number",
@ -266,6 +273,7 @@
"wiki",
"reviewable_id",
"reviewable_score_count",
"reviewable_score_pending_count"
"reviewable_score_pending_count",
"post_url"
]
}

View File

@ -26,6 +26,9 @@
"post_type": {
"type": "integer"
},
"posts_count": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
@ -205,6 +208,9 @@
"reviewable_score_pending_count": {
"type": "integer"
},
"post_url": {
"type": "string"
},
"mentioned_users": {
"type": "array",
"items": {}
@ -230,6 +236,7 @@
"cooked",
"post_number",
"post_type",
"posts_count",
"updated_at",
"reply_count",
"reply_to_post_number",
@ -269,7 +276,8 @@
"wiki",
"reviewable_id",
"reviewable_score_count",
"reviewable_score_pending_count"
"reviewable_score_pending_count",
"post_url"
]
}
},

View File

@ -31,6 +31,9 @@
"post_type": {
"type": "integer"
},
"posts_count": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
@ -142,8 +145,7 @@
},
"actions_summary": {
"type": "array",
"items":
{
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
@ -214,10 +216,12 @@
"reviewable_score_pending_count": {
"type": "integer"
},
"post_url": {
"type": "string"
},
"mentioned_users": {
"type": "array",
"items": {
}
"items": {}
}
},
"required": [
@ -229,6 +233,7 @@
"cooked",
"post_number",
"post_type",
"posts_count",
"updated_at",
"reply_count",
"reply_to_post_number",
@ -268,6 +273,7 @@
"wiki",
"reviewable_id",
"reviewable_score_count",
"reviewable_score_pending_count"
"reviewable_score_pending_count",
"post_url"
]
}

View File

@ -18,6 +18,7 @@ RSpec.describe WebHookPostSerializer do
:cooked,
:post_number,
:post_type,
:posts_count,
:updated_at,
:reply_count,
:reply_to_post_number,
@ -50,6 +51,7 @@ RSpec.describe WebHookPostSerializer do
:reviewable_id,
:reviewable_score_count,
:reviewable_score_pending_count,
:post_url,
:topic_posts_count,
:topic_filtered_posts_count,
:topic_archetype,