diff --git a/app/assets/javascripts/admin/addon/components/dashboard-new-features.js b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js
new file mode 100644
index 00000000000..937340c1d36
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/components/dashboard-new-features.js
@@ -0,0 +1,26 @@
+import Component from "@ember/component";
+import { action } from "@ember/object";
+import { ajax } from "discourse/lib/ajax";
+
+export default Component.extend({
+ newFeatures: null,
+ releaseNotesLink: null,
+
+ init() {
+ this._super(...arguments);
+
+ ajax("/admin/dashboard/new-features.json").then((json) => {
+ this.setProperties({
+ newFeatures: json.new_features,
+ releaseNotesLink: json.release_notes_link,
+ });
+ });
+ },
+
+ @action
+ dismissNewFeatures() {
+ ajax("/admin/dashboard/mark-new-features-as-seen.json", {
+ type: "PUT",
+ }).then(() => this.set("newFeatures", null));
+ },
+});
diff --git a/app/assets/javascripts/admin/addon/models/admin-dashboard.js b/app/assets/javascripts/admin/addon/models/admin-dashboard.js
index 400ebe161bb..c0c9f86702c 100644
--- a/app/assets/javascripts/admin/addon/models/admin-dashboard.js
+++ b/app/assets/javascripts/admin/addon/models/admin-dashboard.js
@@ -14,6 +14,7 @@ AdminDashboard.reopenClass({
return ajax("/admin/dashboard.json").then((json) => {
const model = AdminDashboard.create();
model.set("version_check", json.version_check);
+
return model;
});
},
diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs
new file mode 100644
index 00000000000..2aff2986402
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-feature-item.hbs
@@ -0,0 +1,13 @@
+
+
{{item.emoji}}
+
+
+
{{item.description}}
+
+
diff --git a/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs
new file mode 100644
index 00000000000..190e53a4326
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/templates/components/dashboard-new-features.hbs
@@ -0,0 +1,21 @@
+{{#if newFeatures}}
+
+
+
{{replace-emoji (i18n "admin.dashboard.new_features.title") }}
+
+
+
+ {{#each newFeatures as |feature|}}
+ {{dashboard-new-feature-item item=feature}}
+ {{/each}}
+
+
+
+{{/if}}
diff --git a/app/assets/javascripts/admin/addon/templates/dashboard.hbs b/app/assets/javascripts/admin/addon/templates/dashboard.hbs
index 0e4f4beec26..cb87e84b471 100644
--- a/app/assets/javascripts/admin/addon/templates/dashboard.hbs
+++ b/app/assets/javascripts/admin/addon/templates/dashboard.hbs
@@ -1,3 +1,5 @@
+{{dashboard-new-features}}
+
{{plugin-outlet name="admin-dashboard-top"}}
{{#if showVersionChecks}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js b/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js
index b573383066b..ac67b052df5 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/dashboard-test.js
@@ -127,6 +127,13 @@ acceptance("Dashboard", function (needs) {
"its set the value of the filter from the query params"
);
});
+
+ test("new features", async function (assert) {
+ await visit("/admin");
+
+ assert.ok(exists(".dashboard-new-features"));
+ assert.ok(exists(".dashboard-new-features .new-features-release-notes"));
+ });
});
acceptance("Dashboard: dashboard_visible_tabs", function (needs) {
diff --git a/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js b/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js
new file mode 100644
index 00000000000..fe9d50b791f
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/fixtures/dashboard-new-features.js
@@ -0,0 +1,32 @@
+export default {
+ "/admin/dashboard/new-features.json": {
+ new_features: [
+ {
+ id: 1,
+ user_id: 127,
+ emoji: "😎",
+ title: "New color palettes",
+ description:
+ "New light and dark color palettes that adhere to Web Content Accessibility Guidelines. ",
+ tier: [],
+ link: "https://meta.discourse.org",
+ created_at: "2021-01-18T19:59:29.666Z",
+ updated_at: "2021-01-19T19:33:16.150Z",
+ },
+ {
+ id: 7,
+ user_id: 127,
+ emoji: "👱♀️",
+ title: "Suspend users quickly",
+ description:
+ "Staff can now suspend or silence a user immediately, without needing to visit the review queue or admin page. ",
+ tier: [],
+ link: "",
+ created_at: "2021-01-19T19:20:09.757Z",
+ updated_at: "2021-01-19T19:20:09.757Z",
+ }
+ ],
+ release_notes_link:
+ "https://meta.discourse.org/c/feature/announcements?tags=release-notes\u0026before=0",
+ },
+};
diff --git a/app/assets/stylesheets/common/admin/dashboard.scss b/app/assets/stylesheets/common/admin/dashboard.scss
index 8d38420b7bd..0e553c4a9a8 100644
--- a/app/assets/stylesheets/common/admin/dashboard.scss
+++ b/app/assets/stylesheets/common/admin/dashboard.scss
@@ -612,3 +612,42 @@
font-size: $font-up-3;
}
}
+
+.dashboard-new-features {
+ .section-body {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ grid-gap: 1.5em;
+ }
+
+ .section-footer {
+ margin: 1.5em;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ .btn {
+ margin-left: 1em;
+ }
+ }
+}
+
+.admin-new-feature-item {
+ display: flex;
+ align-items: flex-start;
+
+ .new-feature-emoji {
+ font-size: 3.5em;
+ padding-right: 0.5em;
+ padding-left: 0.5em;
+ }
+
+ .new-feature-content {
+ padding-right: 0.5em;
+ align-self: center;
+ .header {
+ font-size: $font-up-1;
+ font-weight: bold;
+ margin-bottom: 0.5em;
+ }
+ }
+}
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index eb246d75e54..1b75618f883 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -22,4 +22,15 @@ class Admin::DashboardController < Admin::AdminController
def problems
render_json_dump(problems: AdminDashboardData.fetch_problems(check_force_https: request.ssl?))
end
+
+ def new_features
+ data = { new_features: DiscourseUpdates.unseen_new_features(current_user.id) }
+ data.merge!(release_notes_link: AdminDashboardGeneralData.fetch_cached_stats["release_notes_link"])
+ render json: data
+ end
+
+ def mark_new_features_as_seen
+ DiscourseUpdates.mark_new_features_as_seen(current_user.id)
+ render json: success_json
+ end
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 330dcb19814..30f998f7b36 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -3601,6 +3601,10 @@ en:
installed_version: "Installed"
latest_version: "Latest"
problems_found: "Some advice based on your current site settings"
+ new_features:
+ title: "🎁 New Features"
+ dismiss: "Dismiss"
+ learn_more: "Learn more"
last_checked: "Last checked"
refresh_problems: "Refresh"
no_problems: "No problems were found."
diff --git a/config/routes.rb b/config/routes.rb
index e30b0a7d2c8..85c40920445 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -260,6 +260,8 @@ Discourse::Application.routes.draw do
get "dashboard/moderation" => "dashboard#moderation"
get "dashboard/security" => "dashboard#security"
get "dashboard/reports" => "dashboard#reports"
+ get "dashboard/new-features" => "dashboard#new_features"
+ put "dashboard/mark-new-features-as-seen" => "dashboard#mark_new_features_as_seen"
resources :dashboard, only: [:index] do
collection do
diff --git a/config/site_settings.yml b/config/site_settings.yml
index a64dba360ce..d32b2ebc49d 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2134,6 +2134,10 @@ uncategorized:
client: true
hidden: true
+ check_for_new_features:
+ default: false
+ hidden: true
+
automatically_unpin_topics:
default: true
client: true
diff --git a/lib/discourse_updates.rb b/lib/discourse_updates.rb
index 6a7dc2a54b4..d5d2f3b2647 100644
--- a/lib/discourse_updates.rb
+++ b/lib/discourse_updates.rb
@@ -115,6 +115,38 @@ module DiscourseUpdates
keys.present? ? keys.map { |k| Discourse.redis.hgetall(k) } : []
end
+ def perform_new_feature_check
+ response = Excon.new(new_features_endpoint).request(expects: [200], method: :Get)
+ json = JSON.parse(response.body)
+ Discourse.redis.set(new_features_key, response.body)
+ end
+
+ def unseen_new_features(user_id)
+ entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil
+ return nil if entries.nil?
+
+ last_seen = new_features_last_seen(user_id)
+
+ if last_seen.present?
+ entries.select! { |item| Time.zone.parse(item["created_at"]) > last_seen }
+ end
+
+ entries.sort { |item| Time.zone.parse(item["created_at"]) }
+ end
+
+ def new_features_last_seen(user_id)
+ last_seen = Discourse.redis.get new_features_last_seen_key(user_id)
+ return nil if last_seen.blank?
+ Time.zone.parse(last_seen)
+ end
+
+ def mark_new_features_as_seen(user_id)
+ entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil
+ return nil if entries.nil?
+ last_seen = entries.max_by { |x| x["created_at"] }
+ Discourse.redis.set(new_features_last_seen_key(user_id), last_seen["created_at"])
+ end
+
private
def last_installed_version_key
@@ -144,5 +176,17 @@ module DiscourseUpdates
def missing_versions_key_prefix
'missing_version'
end
+
+ def new_features_endpoint
+ 'https://meta.discourse.org/new-features.json'
+ end
+
+ def new_features_key
+ 'new_features'
+ end
+
+ def new_features_last_seen_key(user_id)
+ "new_features_last_seen_user_#{user_id}"
+ end
end
end
diff --git a/spec/components/discourse_updates_spec.rb b/spec/components/discourse_updates_spec.rb
index 4b83510c245..e3f911ca6f2 100644
--- a/spec/components/discourse_updates_spec.rb
+++ b/spec/components/discourse_updates_spec.rb
@@ -144,4 +144,72 @@ describe DiscourseUpdates do
include_examples "when last_installed_version is old"
end
end
+
+ context 'new features' do
+ fab!(:admin) { Fabricate(:admin) }
+ fab!(:admin2) { Fabricate(:admin) }
+ let!(:last_item_date) { 5.minutes.ago }
+ let!(:sample_features) { [
+ { "emoji" => "🤾", "title" => "Super Fruits", "description" => "Taste explosion!", "created_at" => 40.minutes.ago },
+ { "emoji" => "🙈", "title" => "Fancy Legumes", "description" => "Magic legumes!", "created_at" => 15.minutes.ago },
+ { "emoji" => "🤾", "title" => "Quality Veggies", "description" => "Green goodness!", "created_at" => last_item_date },
+ ] }
+
+ before(:each) do
+ Discourse.redis.del "new_features_last_seen_user_#{admin.id}"
+ Discourse.redis.del "new_features_last_seen_user_#{admin2.id}"
+ Discourse.redis.del "new_features"
+
+ Discourse.redis.set('new_features', MultiJson.dump(sample_features))
+ end
+
+ it 'returns all items on the first run' do
+ result = DiscourseUpdates.unseen_new_features(admin.id)
+
+ expect(result.length).to eq(3)
+ expect(result[2]["title"]).to eq("Super Fruits")
+ end
+
+ it 'returns only unseen items by user' do
+ DiscourseUpdates.stubs(:new_features_last_seen).with(admin.id).returns(10.minutes.ago)
+ DiscourseUpdates.stubs(:new_features_last_seen).with(admin2.id).returns(30.minutes.ago)
+
+ result = DiscourseUpdates.unseen_new_features(admin.id)
+ expect(result.length).to eq(1)
+ expect(result[0]["title"]).to eq("Quality Veggies")
+
+ result2 = DiscourseUpdates.unseen_new_features(admin2.id)
+ expect(result2.length).to eq(2)
+ expect(result2[0]["title"]).to eq("Quality Veggies")
+ expect(result2[1]["title"]).to eq("Fancy Legumes")
+ end
+
+ it 'can mark features as seen for a given user' do
+ expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_present
+
+ DiscourseUpdates.mark_new_features_as_seen(admin.id)
+ expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_empty
+
+ # doesn't affect another user
+ expect(DiscourseUpdates.unseen_new_features(admin2.id)).to be_present
+
+ end
+
+ it 'correctly sees newly added features as unseen' do
+ DiscourseUpdates.mark_new_features_as_seen(admin.id)
+ expect(DiscourseUpdates.unseen_new_features(admin.id)).to be_empty
+ expect(DiscourseUpdates.new_features_last_seen(admin.id)).to be_within(1.second).of (last_item_date)
+
+ updated_features = [
+ { "emoji" => "🤾", "title" => "Brand New Item", "created_at" => 2.minutes.ago }
+ ]
+ updated_features += sample_features
+
+ Discourse.redis.set('new_features', MultiJson.dump(updated_features))
+
+ result = DiscourseUpdates.unseen_new_features(admin.id)
+ expect(result.length).to eq(1)
+ expect(result[0]["title"]).to eq("Brand New Item")
+ end
+ end
end
diff --git a/spec/requests/admin/dashboard_controller_spec.rb b/spec/requests/admin/dashboard_controller_spec.rb
index 46602ae91d3..b838f169dc7 100644
--- a/spec/requests/admin/dashboard_controller_spec.rb
+++ b/spec/requests/admin/dashboard_controller_spec.rb
@@ -15,6 +15,15 @@ describe Admin::DashboardController do
context 'while logged in as an admin' do
fab!(:admin) { Fabricate(:admin) }
+ def populate_new_features
+ sample_features = [
+ { "id" => "1", "emoji" => "🤾", "title" => "Cool Beans", "description" => "Now beans are included", "created_at" => Time.zone.now - 40.minutes },
+ { "id" => "2", "emoji" => "🙈", "title" => "Fancy Legumes", "description" => "Legumes too!", "created_at" => Time.zone.now - 20.minutes }
+ ]
+
+ Discourse.redis.set('new_features', MultiJson.dump(sample_features))
+ end
+
before do
sign_in(admin)
end
@@ -77,5 +86,49 @@ describe Admin::DashboardController do
end
end
end
+
+ describe '#new_features' do
+ before do
+ Discourse.redis.del "new_features_last_seen_user_#{admin.id}"
+ Discourse.redis.del "new_features"
+ end
+
+ it 'is empty by default' do
+ get "/admin/dashboard/new-features.json"
+ expect(response.status).to eq(200)
+ json = response.parsed_body
+ expect(json['new_features']).to eq(nil)
+ end
+
+ it 'fails gracefully for invalid JSON' do
+ Discourse.redis.set("new_features", "INVALID JSON")
+ get "/admin/dashboard/new-features.json"
+ expect(response.status).to eq(200)
+ json = response.parsed_body
+ expect(json['new_features']).to eq(nil)
+ end
+
+ it 'includes new features when available' do
+ populate_new_features
+
+ get "/admin/dashboard/new-features.json"
+ expect(response.status).to eq(200)
+ json = response.parsed_body
+
+ expect(json['new_features'].length).to eq(2)
+ expect(json['new_features'][0]["emoji"]).to eq("🙈")
+ expect(json['new_features'][0]["title"]).to eq("Fancy Legumes")
+ end
+ end
+
+ describe '#mark_new_features_as_seen' do
+ it 'resets last seen for a given user' do
+ populate_new_features
+ put "/admin/dashboard/mark-new-features-as-seen.json"
+
+ expect(response.status).to eq(200)
+ expect(DiscourseUpdates.new_features_last_seen(admin.id)).not_to eq(nil)
+ end
+ end
end
end