diff --git a/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6
index 5f4592aeed3..468734633e9 100644
--- a/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6
+++ b/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6
@@ -1,5 +1,6 @@
import discourseComputed from "discourse-common/utils/decorators";
import { alias, or, and } from "@ember/object/computed";
+import { propertyEqual } from "discourse/lib/computed";
import Component from "@ember/component";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
@@ -9,6 +10,11 @@ export default Component.extend({
// Allow us to extend it
layoutName: "components/topic-footer-buttons",
+ topicFeaturedOnProfile: propertyEqual(
+ "topic.id",
+ "currentUser.featured_topic.id"
+ ),
+
@discourseComputed("topic.isPrivateMessage")
canArchive(isPM) {
return this.siteSettings.enable_personal_messages && isPM;
@@ -58,5 +64,19 @@ export default Component.extend({
@discourseComputed("topic.message_archived")
archiveLabel: archived =>
- archived ? "topic.move_to_inbox.title" : "topic.archive_message.title"
+ archived ? "topic.move_to_inbox.title" : "topic.archive_message.title",
+
+ @discourseComputed(
+ "topic.user_id",
+ "topic.isPrivateMessage",
+ "topic.category.read_restricted"
+ )
+ showToggleFeatureOnProfileButton(userId, isPm, restricted) {
+ return (
+ this.siteSettings.allow_featured_topic_on_user_profiles &&
+ userId === this.currentUser.get("id") &&
+ !restricted &&
+ !isPm
+ );
+ }
});
diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6
index 255e494f21c..6607fe7961e 100644
--- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6
+++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6
@@ -50,6 +50,11 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
// If inside a topic
topicPostCount: null,
+ showFeaturedTopic: and(
+ "user.featured_topic",
+ "siteSettings.allow_featured_topic_on_user_profiles"
+ ),
+
@discourseComputed("user.staff")
staff: isStaff => (isStaff ? "staff" : ""),
diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6
index d273e511534..ed1f8d8add4 100644
--- a/app/assets/javascripts/discourse/controllers/topic.js.es6
+++ b/app/assets/javascripts/discourse/controllers/topic.js.es6
@@ -679,6 +679,19 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
+ toggleFeaturedOnProfile() {
+ if (!this.currentUser) return;
+
+ if (
+ this.currentUser.featured_topic &&
+ this.currentUser.featured_topic.id !== this.model.id
+ ) {
+ bootbox.confirm(I18n.t("topic.remove_from_profile.warning"), result => {
+ if (result) return this._performToggleFeaturedOnProfile();
+ });
+ } else return this._performToggleFeaturedOnProfile();
+ },
+
jumpToIndex(index) {
this._jumpToIndex(index);
},
@@ -1070,6 +1083,10 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
+ _performToggleFeaturedOnProfile() {
+ this.model.toggleFeaturedOnProfile(this.currentUser).catch(popupAjaxError);
+ },
+
_jumpToIndex(index) {
const postStream = this.get("model.postStream");
diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6
index 507ca246904..36bd0c9213f 100644
--- a/app/assets/javascripts/discourse/controllers/user.js.es6
+++ b/app/assets/javascripts/discourse/controllers/user.js.es6
@@ -61,6 +61,11 @@ export default Controller.extend(CanCheckEmails, {
"hasReceivedWarnings"
),
+ showFeaturedTopic: and(
+ "model.featured_topic",
+ "siteSettings.allow_featured_topic_on_user_profiles"
+ ),
+
@discourseComputed("model.suspended", "currentUser.staff")
isNotSuspendedOrIsStaff(suspended, isStaff) {
return !suspended || isStaff;
diff --git a/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6
index 3cd0827a6f1..4dbca6a9a5e 100644
--- a/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6
+++ b/app/assets/javascripts/discourse/initializers/topic-footer-buttons.js.es6
@@ -166,5 +166,34 @@ export default {
return this.site.mobileView;
}
});
+
+ registerTopicFooterButton({
+ dependentKeys: ["currentUser.featured_topic"],
+ id: "toggle-feature-on-profile",
+ icon: "id-card",
+ priority: 300,
+ label() {
+ return this.topicFeaturedOnProfile
+ ? "topic.remove_from_profile.title"
+ : "topic.feature_on_profile.title";
+ },
+ title() {
+ return this.topicFeaturedOnProfile
+ ? "topic.remove_from_profile.help"
+ : "topic.feature_on_profile.help";
+ },
+ classNames() {
+ return this.topicFeaturedOnProfile
+ ? ["feature-on-profile", "featured-on-profile"]
+ : ["feature-on-profile"];
+ },
+ action: "toggleFeaturedOnProfile",
+ displayed() {
+ return this.showToggleFeatureOnProfileButton;
+ },
+ dropdown() {
+ return this.site.mobileView;
+ }
+ });
}
};
diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6
index c392bc20ef6..86515edadcc 100644
--- a/app/assets/javascripts/discourse/models/topic.js.es6
+++ b/app/assets/javascripts/discourse/models/topic.js.es6
@@ -444,6 +444,21 @@ const Topic = RestModel.extend({
});
},
+ toggleFeaturedOnProfile(user) {
+ const removing = user.get("featured_topic.id") === this.id;
+ const path = removing ? "clear-featured-topic" : "feature-topic";
+ return ajax(`/u/${user.username}/${path}`, {
+ type: "PUT",
+ data: { topic_id: this.id }
+ })
+ .then(() => {
+ const featuredTopic = removing ? null : this;
+ user.set("featured_topic", featuredTopic);
+ return;
+ })
+ .catch(popupAjaxError);
+ },
+
createGroupInvite(group) {
return ajax(`/t/${this.id}/invite-group`, {
type: "POST",
diff --git a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
index a4602fa48e5..4d4cae80996 100644
--- a/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
+++ b/app/assets/javascripts/discourse/templates/components/user-card-contents.hbs
@@ -10,13 +10,13 @@
-
+
-
+
-
+
{{else}}
@@ -140,8 +140,17 @@
{{/if}}
+ {{#if showFeaturedTopic}}
+
+
+ {{i18n 'user.featured_topic'}}
+ {{#link-to "topic" user.featured_topic.slug user.featured_topic.id }}{{user.featured_topic.fancy_title}}{{/link-to}}
+
+
+ {{/if}}
+
{{#if hasLocationOrWebsite}}
-
+
{{#if user.location}}
{{d-icon "map-marker-alt"}}
@@ -163,7 +172,7 @@
{{/if}}
-
+
{{#unless user.profile_hidden}}
{{#if user.last_posted_at}}
@@ -203,7 +212,7 @@
{{#if publicUserFields}}
-
+
{{#each publicUserFields as |uf|}}
{{#if uf.value}}
@@ -220,7 +229,7 @@
{{plugin-outlet name="user-card-before-badges" args=(hash user=user)}}
{{#if showBadges}}
-
+
{{#if user.featured_user_badges}}
{{#each user.featured_user_badges as |ub|}}
diff --git a/app/assets/javascripts/discourse/templates/preferences/profile.hbs b/app/assets/javascripts/discourse/templates/preferences/profile.hbs
index 7bd63f09302..66ff8457589 100644
--- a/app/assets/javascripts/discourse/templates/preferences/profile.hbs
+++ b/app/assets/javascripts/discourse/templates/preferences/profile.hbs
@@ -57,6 +57,18 @@
{{/if}}
+{{#if model.featured_topic}}
+
+
+
+
+ {{i18n 'user.change_featured_topic.instructions'}}
+
+
+{{/if}}
+
{{plugin-outlet name="user-preferences-profile" args=(hash model=model save=(action "save"))}}
{{plugin-outlet name="user-custom-preferences" args=(hash model=model)}}
diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs
index 9d77e4a4b5e..ad6a1557840 100644
--- a/app/assets/javascripts/discourse/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/templates/topic.hbs
@@ -313,7 +313,8 @@
toggleArchiveMessage=(action "toggleArchiveMessage")
editFirstPost=(action "editFirstPost")
deferTopic=(action "deferTopic")
- replyToPost=(action "replyToPost")}}
+ replyToPost=(action "replyToPost")
+ toggleFeaturedOnProfile=(action "toggleFeaturedOnProfile")}}
{{else}}
+
+ {{#if showFeaturedTopic}}
+
+ {{i18n 'user.featured_topic'}}
+ {{#link-to "topic" model.featured_topic.slug model.featured_topic.id}}{{model.featured_topic.fancy_title}}{{/link-to}}
+
+ {{/if}}
+
{{#if model.location}}
{{d-icon "map-marker-alt"}} {{model.location}}
{{/if}}
{{#if model.website_name}}
diff --git a/app/assets/stylesheets/common/components/user-card.scss b/app/assets/stylesheets/common/components/user-card.scss
index 21e15ec0b74..506e6e37061 100644
--- a/app/assets/stylesheets/common/components/user-card.scss
+++ b/app/assets/stylesheets/common/components/user-card.scss
@@ -181,56 +181,62 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards
margin-top: 0.5em;
}
}
+ // featured topic
+ .featured-topic {
+ .desc {
+ color: $primary-high;
+ }
+ a {
+ color: $primary;
+ text-decoration: underline;
+ }
+ }
+
// location and website
- .third-row {
- .location-and-website {
+ .location-and-website {
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+ align-items: center;
+ .location,
+ .website-name {
display: flex;
- flex-wrap: wrap;
- width: 100%;
+ overflow: hidden;
align-items: center;
- .location,
- .website-name {
- display: flex;
- overflow: hidden;
- align-items: center;
- .d-icon {
- margin-right: 0.25em;
- }
- }
- .website-name a,
- .location span {
- @include ellipsis;
- color: $primary;
- }
- .location {
- margin-right: 0.5em;
- }
- .website-name a {
- text-decoration: underline;
+ .d-icon {
+ margin-right: 0.25em;
}
}
+ .website-name a,
+ .location span {
+ @include ellipsis;
+ color: $primary;
+ }
+ .location {
+ margin-right: 0.5em;
+ }
+ .website-name a {
+ text-decoration: underline;
+ }
}
// custom user fields
- .fifth-row {
- .public-user-fields {
- margin: 0;
- }
+ .public-user-fields {
+ margin: 0;
}
+
// badges
- .sixth-row {
- .badge-section {
- display: flex;
- align-items: flex-start;
- .user-badge {
- @include ellipsis;
- background: $primary-very-low;
- border: 1px solid $primary-low;
- color: $primary;
- }
- .more-user-badges {
- a {
- @extend .user-badge;
- }
+ .badge-section {
+ display: flex;
+ align-items: flex-start;
+ .user-badge {
+ @include ellipsis;
+ background: $primary-very-low;
+ border: 1px solid $primary-low;
+ color: $primary;
+ }
+ .more-user-badges {
+ a {
+ @extend .user-badge;
}
}
}
diff --git a/app/assets/stylesheets/desktop/components/user-card.scss b/app/assets/stylesheets/desktop/components/user-card.scss
index 7bc2b0aea1d..f4aa977068f 100644
--- a/app/assets/stylesheets/desktop/components/user-card.scss
+++ b/app/assets/stylesheets/desktop/components/user-card.scss
@@ -37,16 +37,14 @@
// styles for user cards only
#user-card {
// badges
- .sixth-row {
- .badge-section {
- .user-badge {
- display: block;
- max-width: 185px;
- margin: 0 0.5em 0 0;
- }
- .more-user-badges {
- max-width: 125px;
- }
+ .badge-section {
+ .user-badge {
+ display: block;
+ max-width: 185px;
+ margin: 0 0.5em 0 0;
+ }
+ .more-user-badges {
+ max-width: 125px;
}
}
}
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index 4abaa8d21ac..ee169e499f9 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -451,6 +451,9 @@ nav.post-controls {
.bookmark.bookmarked .d-icon-bookmark {
color: $tertiary;
}
+ .feature-on-profile.featured-on-profile .d-icon-id-card {
+ color: $tertiary;
+ }
}
#topic-footer-button {
diff --git a/app/assets/stylesheets/mobile/components/topic-footer-mobile-dropdown.scss b/app/assets/stylesheets/mobile/components/topic-footer-mobile-dropdown.scss
index c5403eaff44..afe761300a4 100644
--- a/app/assets/stylesheets/mobile/components/topic-footer-mobile-dropdown.scss
+++ b/app/assets/stylesheets/mobile/components/topic-footer-mobile-dropdown.scss
@@ -5,5 +5,10 @@
color: $tertiary;
}
}
+ &.featured-on-profile {
+ .d-icon {
+ color: $tertiary;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/mobile/components/user-card.scss b/app/assets/stylesheets/mobile/components/user-card.scss
index 0345aaf7433..728f93a6475 100644
--- a/app/assets/stylesheets/mobile/components/user-card.scss
+++ b/app/assets/stylesheets/mobile/components/user-card.scss
@@ -50,30 +50,28 @@ $avatar_width: 120px;
// styles for user cards only
#user-card {
// badges
- .sixth-row {
- .badge-section {
- flex-wrap: wrap;
- > span {
- display: flex;
- flex: 0 1 50%;
- max-width: 50%; // for text ellipsis
- padding: 2px 0;
- box-sizing: border-box;
- &:nth-of-type(1),
- &:nth-of-type(3) {
- padding-right: 4px;
- }
- a {
- width: 100%;
- display: flex;
- }
+ .badge-section {
+ flex-wrap: wrap;
+ > span {
+ display: flex;
+ flex: 0 1 50%;
+ max-width: 50%; // for text ellipsis
+ padding: 2px 0;
+ box-sizing: border-box;
+ &:nth-of-type(1),
+ &:nth-of-type(3) {
+ padding-right: 4px;
}
- .user-badge {
- display: flex;
- margin: 0;
+ a {
width: 100%;
+ display: flex;
}
}
+ .user-badge {
+ display: flex;
+ margin: 0;
+ width: 100%;
+ }
}
}
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 3c2fe8382d4..da3a23dfe80 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -11,13 +11,14 @@ class UsersController < ApplicationController
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
:update_second_factor, :create_second_factor_backup, :select_avatar,
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
- :create_second_factor_security_key
+ :create_second_factor_security_key, :feature_topic, :clear_featured_topic
]
skip_before_action :check_xhr, only: [
:show, :badges, :password_reset, :update, :account_created,
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
- :my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary
+ :my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary,
+ :feature_topic, :clear_featured_topic
]
before_action :second_factor_check_confirmed_password, only: [
@@ -1403,6 +1404,22 @@ class UsersController < ApplicationController
render json: success_json
end
+ def feature_topic
+ user = fetch_user_from_params
+ topic = Topic.find(params[:topic_id].to_i)
+
+ raise Discourse::InvalidAccess.new unless topic && guardian.can_feature_topic?(user, topic)
+ user.user_profile.update(featured_topic_id: topic.id)
+ render json: success_json
+ end
+
+ def clear_featured_topic
+ user = fetch_user_from_params
+ guardian.ensure_can_edit!(user)
+ user.user_profile.update(featured_topic_id: nil)
+ render json: success_json
+ end
+
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
@@ -1457,7 +1474,8 @@ class UsersController < ApplicationController
:dismissed_banner_key,
:profile_background_upload_url,
:card_background_upload_url,
- :primary_group_id
+ :primary_group_id,
+ :featured_topic_id
]
editable_custom_fields = User.editable_user_custom_fields(by_staff: current_user.try(:staff?))
@@ -1532,4 +1550,5 @@ class UsersController < ApplicationController
challenge: secure_session["staged-webauthn-challenge-#{user.id}"]
}
end
+
end
diff --git a/app/models/topic.rb b/app/models/topic.rb
index 6e10a13c2f4..113d2fe5c31 100644
--- a/app/models/topic.rb
+++ b/app/models/topic.rb
@@ -127,6 +127,7 @@ class Topic < ActiveRecord::Base
has_many :invites, through: :topic_invites, source: :invite
has_many :topic_timers, dependent: :destroy
has_many :reviewables
+ has_many :user_profiles
has_one :user_warning
has_one :first_post, -> { where post_number: 1 }, class_name: 'Post'
@@ -238,6 +239,8 @@ class Topic < ActiveRecord::Base
after_update do
if saved_changes[:category_id] && self.tags.present?
CategoryTagStat.topic_moved(self, *saved_changes[:category_id])
+ elsif saved_changes[:category_id] && self.category&.read_restricted?
+ UserProfile.remove_featured_topic_from_all_profiles(self)
end
end
diff --git a/app/models/topic_converter.rb b/app/models/topic_converter.rb
index 120920b4124..ac353cb31d1 100644
--- a/app/models/topic_converter.rb
+++ b/app/models/topic_converter.rb
@@ -50,6 +50,7 @@ class TopicConverter
add_allowed_users
update_post_uploads_secure_status
+ UserProfile.remove_featured_topic_from_all_profiles(@topic)
Jobs.enqueue(:topic_action_converter, topic_id: @topic.id)
Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id)
diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb
index abd5ba06c25..14f94b4cd01 100644
--- a/app/models/user_profile.rb
+++ b/app/models/user_profile.rb
@@ -10,6 +10,7 @@ class UserProfile < ActiveRecord::Base
belongs_to :card_background_upload, class_name: "Upload"
belongs_to :profile_background_upload, class_name: "Upload"
belongs_to :granted_title_badge, class_name: "Badge"
+ belongs_to :featured_topic, class_name: 'Topic'
validates :bio_raw, length: { maximum: 3000 }
validates :website, url: true, allow_blank: true, if: Proc.new { |c| c.new_record? || c.website_changed? }
@@ -145,6 +146,9 @@ class UserProfile < ActiveRecord::Base
self.errors.add :base, (I18n.t('user.website.domain_not_allowed', domains: allowed_domains.split('|').join(", "))) unless allowed_domains.split('|').include?(domain)
end
+ def self.remove_featured_topic_from_all_profiles(topic)
+ where(featured_topic_id: topic.id).update_all(featured_topic_id: nil)
+ end
end
# == Schema Information
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index a119bb324a8..af6fe8c5722 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -45,7 +45,8 @@ class CurrentUserSerializer < BasicUserSerializer
:second_factor_enabled,
:ignored_users,
:title_count_mode,
- :timezone
+ :timezone,
+ :featured_topic
def groups
object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name.downcase } }
@@ -217,4 +218,8 @@ class CurrentUserSerializer < BasicUserSerializer
def second_factor_enabled
object.totp_enabled? || object.security_keys_enabled?
end
+
+ def featured_topic
+ object.user_profile.featured_topic
+ end
end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index 79111200eff..cb7bc9971e4 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -83,7 +83,8 @@ class UserSerializer < BasicUserSerializer
:second_factor_remaining_backup_codes,
:associated_accounts,
:profile_background_upload_url,
- :card_background_upload_url
+ :card_background_upload_url,
+ :featured_topic
has_one :invited_by, embed: :object, serializer: BasicUserSerializer
has_many :groups, embed: :object, serializer: BasicGroupSerializer
@@ -484,4 +485,7 @@ class UserSerializer < BasicUserSerializer
object.card_background_upload&.url
end
+ def featured_topic
+ object.user_profile.featured_topic
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 2a2df4d9606..148d47de8ee 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -848,6 +848,7 @@ en:
enable_quoting: "Enable quote reply for highlighted text"
enable_defer: "Enable defer to mark topics unread"
change: "change"
+ featured_topic: "Featured Topic"
moderator: "{{user}} is a moderator"
admin: "{{user}} is an admin"
moderator_tooltip: "This user is a moderator"
@@ -1045,6 +1046,10 @@ en:
title: "User Card Background"
instructions: "Background images will be centered and have a default width of 590px."
+ change_featured_topic:
+ title: "Featured Topic"
+ instructions: "To change this, either navigate to the topic to remove it as the featured topic, or feature a different topic."
+
email:
title: "Email"
primary: "Primary Email"
@@ -1997,6 +2002,14 @@ en:
defer:
help: "Mark as unread"
title: "Defer"
+ feature_on_profile:
+ help: "Add a link to this topic on your user card and profile"
+ title: "Feature On Profile"
+ remove_from_profile:
+ warning: "Your profile already has a featured topic. If you continue, this topic will replace the existing topic."
+ help: "Remove the link to this topic on your user profile"
+ title: "Remove From Profile"
+
list: "Topics"
new: "new topic"
unread: "unread"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 8792b061e29..74d947869d1 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1951,6 +1951,8 @@ en:
hide_user_profiles_from_public: "Disable user cards, user profiles and user directory for anonymous users."
+ allow_featured_topic_on_user_profiles: "Allow users to feature a link to a topic on their user card and profile."
+
show_inactive_accounts: "Allow logged in users to browse profiles of inactive accounts."
hide_suspension_reasons: "Don't display suspension reasons publically on user profiles."
diff --git a/config/routes.rb b/config/routes.rb
index 0c32e16ec86..0edafdffa75 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -476,6 +476,8 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/topic-tracking-state" => "users#topic_tracking_state", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/profile-hidden" => "users#profile_hidden"
+ put "#{root_path}/:username/feature-topic" => "users#feature_topic", constraints: { username: RouteFormat.username }
+ put "#{root_path}/:username/clear-featured-topic" => "users#clear_featured_topic", constraints: { username: RouteFormat.username }
end
get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json }
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 698bad76119..53a864d27a1 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -560,6 +560,9 @@ users:
hide_user_profiles_from_public:
default: false
client: true
+ allow_featured_topic_on_user_profiles:
+ default: true
+ client: true
show_inactive_accounts:
default: false
user_website_domains_whitelist:
diff --git a/db/migrate/20191202202212_add_featured_topic_id_to_user_profiles.rb b/db/migrate/20191202202212_add_featured_topic_id_to_user_profiles.rb
new file mode 100644
index 00000000000..7f7e3e75eac
--- /dev/null
+++ b/db/migrate/20191202202212_add_featured_topic_id_to_user_profiles.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddFeaturedTopicIdToUserProfiles < ActiveRecord::Migration[6.0]
+ def change
+ add_column :user_profiles, :featured_topic_id, :integer
+ end
+end
diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb
index 984969b1f22..667888e29c0 100644
--- a/lib/guardian/user_guardian.rb
+++ b/lib/guardian/user_guardian.rb
@@ -123,4 +123,11 @@ module UserGuardian
end
end
end
+
+ def can_feature_topic?(user, topic)
+ return false if !SiteSetting.allow_featured_topic_on_user_profiles?
+ return false if !is_me?(user) && !is_staff?
+ return false if topic.read_restricted_category? || topic.private_message?
+ topic.user_id === user.id
+ end
end
diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb
index 4e09ed8f883..214cb2bbaa1 100644
--- a/lib/post_destroyer.rb
+++ b/lib/post_destroyer.rb
@@ -77,6 +77,7 @@ class PostDestroyer
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
if is_first_post
+ UserProfile.remove_featured_topic_from_all_profiles(@topic)
UserActionManager.topic_destroyed(topic)
DiscourseEvent.trigger(:topic_destroyed, topic, @user)
WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload)
diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb
index da8ddf72d5f..897c450b002 100644
--- a/lib/svg_sprite/svg_sprite.rb
+++ b/lib/svg_sprite/svg_sprite.rb
@@ -125,6 +125,7 @@ module SvgSprite
"heading",
"heart",
"home",
+ "id-card",
"info-circle",
"italic",
"key",
diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb
index 52c7a18b6b1..6de14c80d48 100644
--- a/spec/components/post_destroyer_spec.rb
+++ b/spec/components/post_destroyer_spec.rb
@@ -822,4 +822,14 @@ describe PostDestroyer do
expect(@reviewable_reply.reload.status).to eq Reviewable.statuses[:approved]
end
end
+
+ describe "featured topics for user_profiles" do
+ fab!(:user) { Fabricate(:user) }
+
+ it 'clears the user_profiles featured_topic column' do
+ user.user_profile.update(featured_topic: post.topic)
+ PostDestroyer.new(admin, post).destroy
+ expect(user.user_profile.reload.featured_topic).to eq(nil)
+ end
+ end
end
diff --git a/spec/models/topic_converter_spec.rb b/spec/models/topic_converter_spec.rb
index a697b260d48..b439bf6c805 100644
--- a/spec/models/topic_converter_spec.rb
+++ b/spec/models/topic_converter_spec.rb
@@ -221,5 +221,15 @@ describe TopicConverter do
expect(topic.reload.archetype).to eq("private_message")
end
end
+
+ context 'user_profiles with newly converted PM as featured topic' do
+ it "sets all matching user_profile featured topic ids to nil" do
+ author.user_profile.update(featured_topic: topic)
+ topic.convert_to_private_message(admin)
+
+ expect(author.user_profile.reload.featured_topic).to eq(nil)
+ end
+ end
+
end
end
diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb
index 26e2563cd88..6b1d7a070cb 100644
--- a/spec/models/topic_spec.rb
+++ b/spec/models/topic_spec.rb
@@ -2503,4 +2503,17 @@ describe Topic do
expect(topic.access_topic_via_group).to eq(open_group)
end
end
+
+ describe "#after_update" do
+ fab!(:topic) { Fabricate(:topic, user: user) }
+ fab!(:category) { Fabricate(:category_with_definition, read_restricted: true) }
+
+ it "removes the topic as featured from user profiles if new category is read_restricted" do
+ user.user_profile.update(featured_topic: topic)
+ expect(user.user_profile.featured_topic).to eq(topic)
+
+ topic.update(category: category)
+ expect(user.user_profile.reload.featured_topic).to eq(nil)
+ end
+ end
end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index c0f54163f80..e33e526cae2 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -3831,4 +3831,96 @@ describe UsersController do
end
end
end
+
+ describe '#feature_topic' do
+ fab!(:topic) { Fabricate(:topic) }
+ fab!(:other_user) { Fabricate(:user) }
+ fab!(:private_message) { Fabricate(:private_message_topic, user: other_user) }
+ fab!(:category) { Fabricate(:category_with_definition) }
+
+ describe "site setting enabled" do
+ before do
+ SiteSetting.allow_featured_topic_on_user_profiles = true
+ end
+
+ it 'requires the user to be logged in' do
+ put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(403)
+ end
+
+ it 'returns an error if the the current user does not have access' do
+ sign_in(user)
+ topic.update(user_id: other_user.id)
+ put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(403)
+ end
+
+ it 'returns an error if the user did not create the topic' do
+ sign_in(user)
+ topic.update(user_id: other_user.id)
+ put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(403)
+ end
+
+ it 'returns an error if the topic is a PM' do
+ sign_in(other_user)
+ put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: private_message.id }
+ expect(response.status).to eq(403)
+ end
+
+ it "returns an error if the topic's category is read_restricted" do
+ sign_in(user)
+ category.set_permissions({})
+ topic.update(category_id: category.id)
+ put "/u/#{other_user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(403)
+ end
+
+ it 'sets the user_profiles featured_topic correctly' do
+ sign_in(user)
+ topic.update(user_id: user.id)
+ put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(200)
+ expect(user.user_profile.featured_topic).to eq topic
+ end
+
+ describe "site setting disabled" do
+ before do
+ SiteSetting.allow_featured_topic_on_user_profiles = false
+ end
+
+ it "does not allow setting featured_topic for user_profiles" do
+ sign_in(user)
+ topic.update(user_id: user.id)
+ put "/u/#{user.username}/feature-topic.json", params: { topic_id: topic.id }
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+ end
+
+ describe '#clear_featured_topic' do
+ fab!(:topic) { Fabricate(:topic) }
+ fab!(:other_user) { Fabricate(:user) }
+
+ it 'requires the user to be logged in' do
+ put "/u/#{user.username}/clear-featured-topic.json"
+ expect(response.status).to eq(403)
+ end
+
+ it 'returns an error if the the current user does not have access' do
+ sign_in(user)
+ topic.update(user_id: other_user.id)
+ put "/u/#{other_user.username}/clear-featured-topic.json"
+ expect(response.status).to eq(403)
+ end
+
+ it 'clears the user_profiles featured_topic correctly' do
+ sign_in(user)
+ topic.update(user: user)
+ put "/u/#{user.username}/clear-featured-topic.json"
+ expect(response.status).to eq(200)
+ expect(user.user_profile.featured_topic).to eq nil
+ end
+ end
end
diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb
index bdf0f593d65..a019dff350a 100644
--- a/spec/serializers/web_hook_user_serializer_spec.rb
+++ b/spec/serializers/web_hook_user_serializer_spec.rb
@@ -23,18 +23,13 @@ RSpec.describe WebHookUserSerializer do
it 'should only include the required keys' do
count = serializer.as_json.keys.count
- difference = count - 44
+ difference = count - 45
expect(difference).to eq(0), lambda {
- message = ""
-
- if difference < 0
- message << "#{difference * -1} key(s) have been removed from this serializer."
- else
- message << "#{difference} key(s) have been added to this serializer."
- end
-
- message << "\nPlease verify if those key(s) are required as part of the web hook's payload."
+ message = (difference < 0 ?
+ "#{difference * -1} key(s) have been removed from this serializer." :
+ "#{difference} key(s) have been added to this serializer.") +
+ "\nPlease verify if those key(s) are required as part of the web hook's payload."
}
end
end