From 71bf9ec1b23a38396d92caaa40f2d43ef6f2b548 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 4 Jul 2019 10:12:39 +0200 Subject: [PATCH] FEATURE: opt-in guidance on topics for users without access (#7852) Co-Authored-By: majakomel Co-Authored-By: Robin Ward --- .../components/topic-join-group-notice.js.es6 | 17 + .../discourse/controllers/topic.js.es6 | 40 ++ .../javascripts/discourse/models/topic.js.es6 | 8 +- .../discourse/routes/topic-from-params.js.es6 | 14 + .../components/topic-join-group-notice.hbs | 5 + .../javascripts/discourse/templates/topic.hbs | 628 +++++++++--------- app/assets/stylesheets/desktop/topic.scss | 6 +- app/assets/stylesheets/mobile/topic.scss | 6 +- app/controllers/groups_controller.rb | 12 +- app/controllers/topics_controller.rb | 16 + app/models/topic.rb | 9 + .../hidden_topic_view_serializer.rb | 15 + app/views/topics/show.html.erb | 258 +++---- config/locales/client.en.yml | 3 + config/locales/server.en.yml | 1 + lib/guardian/topic_guardian.rb | 4 + spec/models/topic_spec.rb | 27 + spec/requests/topics_controller_spec.rb | 26 +- 18 files changed, 638 insertions(+), 457 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/topic-join-group-notice.hbs create mode 100644 app/serializers/hidden_topic_view_serializer.rb diff --git a/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 b/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 new file mode 100644 index 00000000000..1fd4f9264d7 --- /dev/null +++ b/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 @@ -0,0 +1,17 @@ +import { default as computed } from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + classNames: ["topic-notice"], + + @computed("model.group.{full_name,name,allow_membership_requests}") + accessViaGroupText(group) { + const name = group.full_name || group.name; + const suffix = group.allow_membership_requests ? "request" : "join"; + return I18n.t(`topic.group_${suffix}`, { name }); + }, + + @computed("model.group.allow_membership_requests") + accessViaGroupButtonText(allowRequest) { + return `groups.${allowRequest ? "request" : "join"}`; + } +}); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 41fc658321c..e358cd34c3f 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -941,6 +941,46 @@ export default Ember.Controller.extend(bufferedProperty("model"), { } }, + joinGroup() { + const groupId = this.get("model.group.id"); + if (groupId) { + if (this.get("model.group.allow_membership_requests")) { + const groupName = this.get("model.group.name"); + return ajax(`/groups/${groupName}/request_membership`, { + type: "POST", + data: { + topic_id: this.get("model.id") + } + }) + .then(() => { + bootbox.alert( + I18n.t("topic.group_request_sent", { + group_name: this.get("model.group.full_name") + }), + () => + this.previousURL + ? DiscourseURL.routeTo(this.previousURL) + : DiscourseURL.routeTo("/") + ); + }) + .catch(popupAjaxError); + } else { + const topic = this.model; + return ajax(`/groups/${groupId}/members`, { + type: "PUT", + data: { user_id: this.get("currentUser.id") } + }) + .then(() => + topic.reload().then(() => { + topic.set("view_hidden", false); + topic.postStream.refresh(); + }) + ) + .catch(popupAjaxError); + } + } + }, + replyAsNewTopic(post, quotedText) { const composerController = this.composer; diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index bc537173ca5..4d3056b3bf0 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -481,12 +481,12 @@ const Topic = RestModel.extend({ // Update our attributes from a JSON result updateFromJson(json) { - this.details.updateFromJson(json.details); - const keys = Object.keys(json); - keys.removeObject("details"); - keys.removeObject("post_stream"); + if (!json.view_hidden) { + this.details.updateFromJson(json.details); + keys.removeObjects(["details", "post_stream"]); + } keys.forEach(key => this.set(key, json[key])); }, diff --git a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 index 63a0ebcfb24..c545cffe8d8 100644 --- a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 @@ -35,6 +35,11 @@ export default Discourse.Route.extend({ // TODO we are seeing errors where closest post is null and this is exploding // we need better handling and logging for this condition. + // there are no closestPost for hidden topics + if (topic.view_hidden) { + return; + } + // The post we requested might not exist. Let's find the closest post const closestPost = postStream.closestPostForPostNumber( params.nearPost || 1 @@ -76,5 +81,14 @@ export default Discourse.Route.extend({ console.log("Could not view topic", e); } }); + }, + + actions: { + willTransition() { + this.controllerFor("topic").set( + "previousURL", + document.location.pathname + ); + } } }); diff --git a/app/assets/javascripts/discourse/templates/components/topic-join-group-notice.hbs b/app/assets/javascripts/discourse/templates/components/topic-join-group-notice.hbs new file mode 100644 index 00000000000..3285ea58bcd --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/topic-join-group-notice.hbs @@ -0,0 +1,5 @@ +{{accessViaGroupText}} +{{d-button action=action + class="btn-primary topic-join-group" + icon="user-plus" + label=accessViaGroupButtonText}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 18ba28bb2a1..1673cb3c1b6 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -1,144 +1,95 @@ {{#discourse-topic multiSelect=multiSelect enteredAt=enteredAt topic=model hasScrolled=hasScrolled}} - {{#if model}} - {{add-category-tag-classes category=model.category tags=model.tags}} -
- {{discourse-banner user=currentUser banner=site.banner overlay=hasScrolled hide=model.errorLoading}} -
- {{/if}} - - {{#if showSharedDraftControls}} - {{shared-draft-controls topic=model}} - {{/if}} - - {{plugin-outlet name="topic-above-post-stream" args=(hash model=model)}} - - {{#if model.postStream.loaded}} - {{#if model.postStream.firstPostPresent}} - {{#topic-title cancelled=(action "cancelEditingTopic") save=(action "finishedEditingTopic") model=model}} - {{#if editingTopic}} -
- {{#if model.isPrivateMessage}} - {{d-icon "envelope"}} - {{/if}} - {{text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length autofocus="true"}} - {{#if showCategoryChooser}} - {{category-chooser - class="small" - value=(unbound buffered.category_id) - onSelectAny=(action "topicCategoryChanged")}} - {{/if}} - - {{#if canEditTags}} - {{mini-tag-chooser filterable=true tags=buffered.tags categoryId=buffered.category_id}} - {{/if}} - - {{plugin-outlet name="edit-topic" args=(hash model=model buffered=buffered)}} -
- {{d-button action=(action "finishedEditingTopic") class="btn-primary submit-edit" icon="check"}} - {{d-button action=(action "cancelEditingTopic") class="btn-default cancel-edit" icon="times"}} - - {{#if canRemoveTopicFeaturedLink}} - - {{d-icon "times-circle"}} - {{featuredLinkDomain}} - - {{/if}} -
-
- - {{else}} -

- {{#unless model.is_warning}} - {{#if siteSettings.enable_personal_messages}} - - {{d-icon "envelope"}} - - {{else}} - {{d-icon "envelope"}} - {{/if}} - {{/unless}} - - {{#if model.details.loaded}} - {{topic-status topic=model}} - - {{{model.fancyTitle}}} - - {{/if}} - - {{#if model.details.can_edit}} - {{d-icon "pencil-alt"}} - {{/if}} -

- - {{topic-category topic=model class="topic-category"}} - {{/if}} - {{/topic-title}} + {{#if model.view_hidden}} + {{topic-join-group-notice model=model action=(action "joinGroup")}} + {{else}} + {{#if model}} + {{add-category-tag-classes category=model.category tags=model.tags}} +
+ {{discourse-banner user=currentUser banner=site.banner overlay=hasScrolled hide=model.errorLoading}} +
{{/if}} + {{#if showSharedDraftControls}} + {{shared-draft-controls topic=model}} + {{/if}} -
-
- {{partial "selected-posts"}} -
+ {{plugin-outlet name="topic-above-post-stream" args=(hash model=model)}} - {{#topic-navigation topic=model jumpToDate=(action "jumpToDate") jumpToIndex=(action "jumpToIndex") as |info|}} - {{#if info.renderTimeline}} - {{#if info.renderAdminMenuButton}} - {{topic-admin-menu-button - topic=model - fixed="true" - toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") - deleteTopic=(action "deleteTopic") - recoverTopic=(action "recoverTopic") - toggleClosed=(action "toggleClosed") - toggleArchived=(action "toggleArchived") - toggleVisibility=(action "toggleVisibility") - showTopicStatusUpdate=(route-action "showTopicStatusUpdate") - showFeatureTopic=(route-action "showFeatureTopic") - showChangeTimestamp=(route-action "showChangeTimestamp") - resetBumpDate=(action "resetBumpDate") - convertToPublicTopic=(action "convertToPublicTopic") - convertToPrivateMessage=(action "convertToPrivateMessage")}} + {{#if model.postStream.loaded}} + {{#if model.postStream.firstPostPresent}} + {{#topic-title cancelled=(action "cancelEditingTopic") save=(action "finishedEditingTopic") model=model}} + {{#if editingTopic}} +
+ {{#if model.isPrivateMessage}} + {{d-icon "envelope"}} + {{/if}} + {{text-field id="edit-title" value=buffered.title maxlength=siteSettings.max_topic_title_length autofocus="true"}} + {{#if showCategoryChooser}} + {{category-chooser + class="small" + value=(unbound buffered.category_id) + onSelectAny=(action "topicCategoryChanged")}} + {{/if}} + + {{#if canEditTags}} + {{mini-tag-chooser filterable=true tags=buffered.tags categoryId=buffered.category_id}} + {{/if}} + + {{plugin-outlet name="edit-topic" args=(hash model=model buffered=buffered)}} +
+ {{d-button action=(action "finishedEditingTopic") class="btn-primary submit-edit" icon="check"}} + {{d-button action=(action "cancelEditingTopic") class="btn-default cancel-edit" icon="times"}} + + {{#if canRemoveTopicFeaturedLink}} + + {{d-icon "times-circle"}} + {{featuredLinkDomain}} + + {{/if}} +
+
+ + {{else}} +

+ {{#unless model.is_warning}} + {{#if siteSettings.enable_personal_messages}} + + {{d-icon "envelope"}} + + {{else}} + {{d-icon "envelope"}} + {{/if}} + {{/unless}} + + {{#if model.details.loaded}} + {{topic-status topic=model}} + + {{{model.fancyTitle}}} + + {{/if}} + + {{#if model.details.can_edit}} + {{d-icon "pencil-alt"}} + {{/if}} +

+ + {{topic-category topic=model class="topic-category"}} {{/if}} + {{/topic-title}} + {{/if}} - {{topic-timeline - topic=model - notificationLevel=model.details.notification_level - prevEvent=info.prevEvent - fullscreen=info.topicProgressExpanded - enteredIndex=enteredIndex - loading=model.postStream.loading - jumpToPost=(action "jumpToPost") - jumpTop=(action "jumpTop") - jumpBottom=(action "jumpBottom") - jumpToPostPrompt=(action "jumpToPostPrompt") - jumpToIndex=(action "jumpToIndex") - replyToPost=(action "replyToPost") - toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") - deleteTopic=(action "deleteTopic") - recoverTopic=(action "recoverTopic") - toggleClosed=(action "toggleClosed") - toggleArchived=(action "toggleArchived") - toggleVisibility=(action "toggleVisibility") - showTopicStatusUpdate=(route-action "showTopicStatusUpdate") - showFeatureTopic=(route-action "showFeatureTopic") - showChangeTimestamp=(route-action "showChangeTimestamp") - resetBumpDate=(action "resetBumpDate") - convertToPublicTopic=(action "convertToPublicTopic") - convertToPrivateMessage=(action "convertToPrivateMessage")}} - {{else}} - {{#topic-progress - prevEvent=info.prevEvent - topic=model - expanded=info.topicProgressExpanded - jumpToPost=(action "jumpToPost")}} + +
+
+ {{partial "selected-posts"}} +
+ + {{#topic-navigation topic=model jumpToDate=(action "jumpToDate") jumpToIndex=(action "jumpToIndex") as |info|}} + {{#if info.renderTimeline}} {{#if info.renderAdminMenuButton}} {{topic-admin-menu-button topic=model - openUpwards="true" - rightSide="true" + fixed="true" toggleMultiSelect=(action "toggleMultiSelect") hideMultiSelect=(action "hideMultiSelect") deleteTopic=(action "deleteTopic") @@ -153,212 +104,265 @@ convertToPublicTopic=(action "convertToPublicTopic") convertToPrivateMessage=(action "convertToPrivateMessage")}} {{/if}} - {{/topic-progress}} - {{/if}} - {{/topic-navigation}} -
-
+ {{topic-timeline + topic=model + notificationLevel=model.details.notification_level + prevEvent=info.prevEvent + fullscreen=info.topicProgressExpanded + enteredIndex=enteredIndex + loading=model.postStream.loading + jumpToPost=(action "jumpToPost") + jumpTop=(action "jumpTop") + jumpBottom=(action "jumpBottom") + jumpToPostPrompt=(action "jumpToPostPrompt") + jumpToIndex=(action "jumpToIndex") + replyToPost=(action "replyToPost") + toggleMultiSelect=(action "toggleMultiSelect") + hideMultiSelect=(action "hideMultiSelect") + deleteTopic=(action "deleteTopic") + recoverTopic=(action "recoverTopic") + toggleClosed=(action "toggleClosed") + toggleArchived=(action "toggleArchived") + toggleVisibility=(action "toggleVisibility") + showTopicStatusUpdate=(route-action "showTopicStatusUpdate") + showFeatureTopic=(route-action "showFeatureTopic") + showChangeTimestamp=(route-action "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") + convertToPublicTopic=(action "convertToPublicTopic") + convertToPrivateMessage=(action "convertToPrivateMessage")}} + {{else}} + {{#topic-progress + prevEvent=info.prevEvent + topic=model + expanded=info.topicProgressExpanded + jumpToPost=(action "jumpToPost")}} + {{#if info.renderAdminMenuButton}} + {{topic-admin-menu-button + topic=model + openUpwards="true" + rightSide="true" + toggleMultiSelect=(action "toggleMultiSelect") + hideMultiSelect=(action "hideMultiSelect") + deleteTopic=(action "deleteTopic") + recoverTopic=(action "recoverTopic") + toggleClosed=(action "toggleClosed") + toggleArchived=(action "toggleArchived") + toggleVisibility=(action "toggleVisibility") + showTopicStatusUpdate=(route-action "showTopicStatusUpdate") + showFeatureTopic=(route-action "showFeatureTopic") + showChangeTimestamp=(route-action "showChangeTimestamp") + resetBumpDate=(action "resetBumpDate") + convertToPublicTopic=(action "convertToPublicTopic") + convertToPrivateMessage=(action "convertToPrivateMessage")}} + {{/if}} + {{/topic-progress}} + {{/if}} + {{/topic-navigation}} -
- {{conditional-loading-spinner condition=model.postStream.loadingAbove}} +
+
- {{plugin-outlet name="topic-above-posts" args=(hash model=model)}} +
+ {{conditional-loading-spinner condition=model.postStream.loadingAbove}} - {{#unless model.postStream.loadingFilter}} - {{scrolling-post-stream - posts=postsToRender - canCreatePost=model.details.can_create_post - multiSelect=multiSelect - selectedPostsCount=selectedPostsCount - selectedQuery=selectedQuery - gaps=model.postStream.gaps - showFlags=(action "showPostFlags") - editPost=(action "editPost") - showHistory=(route-action "showHistory") - showLogin=(route-action "showLogin") - showRawEmail=(route-action "showRawEmail") - deletePost=(action "deletePost") - recoverPost=(action "recoverPost") - expandHidden=(action "expandHidden") - newTopicAction=(action "replyAsNewTopic") - toggleBookmark=(action "toggleBookmark") - togglePostType=(action "togglePostType") - rebakePost=(action "rebakePost") - changePostOwner=(action "changePostOwner") - grantBadge=(action "grantBadge") - addNotice=(action "addNotice") - removeNotice=(action "removeNotice") - lockPost=(action "lockPost") - unlockPost=(action "unlockPost") - unhidePost=(action "unhidePost") - replyToPost=(action "replyToPost") - toggleWiki=(action "toggleWiki") - toggleSummary=(action "toggleSummary") - removeAllowedUser=(action "removeAllowedUser") - removeAllowedGroup=(action "removeAllowedGroup") - topVisibleChanged=(action "topVisibleChanged") - currentPostChanged=(action "currentPostChanged") - currentPostScrolled=(action "currentPostScrolled") - bottomVisibleChanged=(action "bottomVisibleChanged") - togglePostSelection=(action "togglePostSelection") - selectReplies=(action "selectReplies") - selectBelow=(action "selectBelow") - fillGapBefore=(action "fillGapBefore") - fillGapAfter=(action "fillGapAfter") - showInvite=(route-action "showInvite")}} - {{/unless}} + {{plugin-outlet name="topic-above-posts" args=(hash model=model)}} - {{conditional-loading-spinner condition=model.postStream.loadingBelow}} -
-
+ {{#unless model.postStream.loadingFilter}} + {{scrolling-post-stream + posts=postsToRender + canCreatePost=model.details.can_create_post + multiSelect=multiSelect + selectedPostsCount=selectedPostsCount + selectedQuery=selectedQuery + gaps=model.postStream.gaps + showFlags=(action "showPostFlags") + editPost=(action "editPost") + showHistory=(route-action "showHistory") + showLogin=(route-action "showLogin") + showRawEmail=(route-action "showRawEmail") + deletePost=(action "deletePost") + recoverPost=(action "recoverPost") + expandHidden=(action "expandHidden") + newTopicAction=(action "replyAsNewTopic") + toggleBookmark=(action "toggleBookmark") + togglePostType=(action "togglePostType") + rebakePost=(action "rebakePost") + changePostOwner=(action "changePostOwner") + grantBadge=(action "grantBadge") + addNotice=(action "addNotice") + removeNotice=(action "removeNotice") + lockPost=(action "lockPost") + unlockPost=(action "unlockPost") + unhidePost=(action "unhidePost") + replyToPost=(action "replyToPost") + toggleWiki=(action "toggleWiki") + toggleSummary=(action "toggleSummary") + removeAllowedUser=(action "removeAllowedUser") + removeAllowedGroup=(action "removeAllowedGroup") + topVisibleChanged=(action "topVisibleChanged") + currentPostChanged=(action "currentPostChanged") + currentPostScrolled=(action "currentPostScrolled") + bottomVisibleChanged=(action "bottomVisibleChanged") + togglePostSelection=(action "togglePostSelection") + selectReplies=(action "selectReplies") + selectBelow=(action "selectBelow") + fillGapBefore=(action "fillGapBefore") + fillGapAfter=(action "fillGapAfter") + showInvite=(route-action "showInvite")}} + {{/unless}} - {{#conditional-loading-spinner condition=model.postStream.loadingFilter}} - {{#if loadedAllPosts}} + {{conditional-loading-spinner condition=model.postStream.loadingBelow}} +
+
- {{#if model.pending_posts}} -
- {{#each model.pending_posts as |pending|}} -
- -
- {{reviewable-created-by user=currentUser tagName=''}} -
- {{reviewable-created-by-name user=currentUser tagName=''}} -
{{cook-text pending.raw}}
+ {{#conditional-loading-spinner condition=model.postStream.loadingFilter}} + {{#if loadedAllPosts}} + + {{#if model.pending_posts}} +
+ {{#each model.pending_posts as |pending|}} +
+ +
+ {{reviewable-created-by user=currentUser tagName=''}} +
+ {{reviewable-created-by-name user=currentUser tagName=''}} +
{{cook-text pending.raw}}
+
+
+
+ {{d-button + class="btn-danger" + label="review.delete" + icon="trash-alt" + action=(action "deletePending" pending) }}
-
- {{d-button - class="btn-danger" - label="review.delete" - icon="trash-alt" - action=(action "deletePending" pending) }} -
-
- {{/each}} -
- {{/if}} - - {{#if model.queued_posts_count}} -
-
- {{{i18n "review.topic_has_pending" count=model.queued_posts_count}}} + {{/each}}
+ {{/if}} - {{#link-to 'review' (query-params topic_id=model.id type="ReviewableQueuedPost" status="pending")}} - {{i18n "review.view_pending"}} - {{/link-to}} -
- {{/if}} + {{#if model.queued_posts_count}} +
+
+ {{{i18n "review.topic_has_pending" count=model.queued_posts_count}}} +
+ + {{#link-to 'review' (query-params topic_id=model.id type="ReviewableQueuedPost" status="pending")}} + {{i18n "review.view_pending"}} + {{/link-to}} +
+ {{/if}} + + {{#if model.private_topic_timer.execute_at}} + {{topic-timer-info + topicClosed=model.closed + statusType=model.private_topic_timer.status_type + executeAt=model.private_topic_timer.execute_at + duration=model.private_topic_timer.duration + removeTopicTimer=(action "removeTopicTimer" model.private_topic_timer.status_type "private_topic_timer")}} + {{/if}} - {{#if model.private_topic_timer.execute_at}} {{topic-timer-info topicClosed=model.closed - statusType=model.private_topic_timer.status_type - executeAt=model.private_topic_timer.execute_at - duration=model.private_topic_timer.duration - removeTopicTimer=(action "removeTopicTimer" model.private_topic_timer.status_type "private_topic_timer")}} - {{/if}} + statusType=model.topic_timer.status_type + executeAt=model.topic_timer.execute_at + basedOnLastPost=model.topic_timer.based_on_last_post + duration=model.topic_timer.duration + categoryId=model.topic_timer.category_id + removeTopicTimer=(action "removeTopicTimer" model.topic_timer.status_type "topic_timer")}} - {{topic-timer-info - topicClosed=model.closed - statusType=model.topic_timer.status_type - executeAt=model.topic_timer.execute_at - basedOnLastPost=model.topic_timer.based_on_last_post - duration=model.topic_timer.duration - categoryId=model.topic_timer.category_id - removeTopicTimer=(action "removeTopicTimer" model.topic_timer.status_type "topic_timer")}} - - {{#if session.showSignupCta}} - {{! replace "Log In to Reply" with the infobox }} - {{signup-cta}} - {{else}} - {{#if currentUser}} - {{plugin-outlet name="topic-above-footer-buttons" args=(hash model=model)}} - - {{topic-footer-buttons - topic=model - toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") - deleteTopic=(action "deleteTopic") - recoverTopic=(action "recoverTopic") - toggleClosed=(action "toggleClosed") - toggleArchived=(action "toggleArchived") - toggleVisibility=(action "toggleVisibility") - showTopicStatusUpdate=(route-action "showTopicStatusUpdate") - showFeatureTopic=(route-action "showFeatureTopic") - showChangeTimestamp=(route-action "showChangeTimestamp") - resetBumpDate=(action "resetBumpDate") - convertToPublicTopic=(action "convertToPublicTopic") - convertToPrivateMessage=(action "convertToPrivateMessage") - toggleBookmark=(action "toggleBookmark") - showFlagTopic=(route-action "showFlagTopic") - toggleArchiveMessage=(action "toggleArchiveMessage") - editFirstPost=(action "editFirstPost") - deferTopic=(action "deferTopic") - replyToPost=(action "replyToPost")}} + {{#if session.showSignupCta}} + {{! replace "Log In to Reply" with the infobox }} + {{signup-cta}} {{else}} -
+
-
+ {{else}} +
+ {{#conditional-loading-spinner condition=noErrorYet}} + {{#if model.notFoundHtml}} +
{{{model.notFoundHtml}}}
+ {{else}} +
+
{{model.message}}
+ {{#if model.noRetry}} + {{#unless currentUser}} + {{d-button action=(route-action "showLogin") class="btn-primary topic-retry" icon="user" label="log_in"}} + {{/unless}} + {{else}} + {{d-button action=(action "retryLoading") class="btn-primary topic-retry" icon="sync" label="errors.buttons.again"}} + {{/if}} +
+ {{conditional-loading-spinner condition=retrying}} + {{/if}} + {{/conditional-loading-spinner}} +
+ {{/if}} -
- {{else}} -
- {{#conditional-loading-spinner condition=noErrorYet}} - {{#if model.notFoundHtml}} -
{{{model.notFoundHtml}}}
- {{else}} -
-
{{model.message}}
- {{#if model.noRetry}} - {{#unless currentUser}} - {{d-button action=(route-action "showLogin") class="btn-primary topic-retry" icon="user" label="log_in"}} - {{/unless}} - {{else}} - {{d-button action=(action "retryLoading") class="btn-primary topic-retry" icon="sync" label="errors.buttons.again"}} - {{/if}} -
- {{conditional-loading-spinner condition=retrying}} - {{/if}} - {{/conditional-loading-spinner}} -
- {{/if}} + {{share-popup topic=model replyAsNewTopic=(action "replyAsNewTopic")}} - {{share-popup topic=model replyAsNewTopic=(action "replyAsNewTopic")}} - - {{#if embedQuoteButton}} - {{quote-button quoteState=quoteState selectText=(action "selectText")}} + {{#if embedQuoteButton}} + {{quote-button quoteState=quoteState selectText=(action "selectText")}} + {{/if}} {{/if}} {{/discourse-topic}} diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index 58183a1d330..279960bf075 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -52,7 +52,8 @@ display: inline; } -.topic-error { +.topic-error, +.topic-notice { padding: 18px; width: 60%; margin-left: auto; @@ -61,7 +62,8 @@ text-align: center; line-height: $line-height-medium; - .topic-retry { + .topic-retry, + .topic-join-group { display: block; margin-top: 28px; margin-left: auto; diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 3a92bc18246..ad07cfb0447 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -147,7 +147,8 @@ } } -.topic-error { +.topic-error, +.topic-notice { padding: 18px; width: 90%; margin-left: auto; @@ -155,7 +156,8 @@ font-size: $font-up-4; line-height: $line-height-medium; - .topic-retry { + .topic-retry, + .topic-join-group { display: block; margin-top: 20px; margin-left: auto; diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 95c982c597c..c1484c27ead 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -420,12 +420,18 @@ class GroupsController < ApplicationController end def request_membership - params.require(:reason) + params.require(:reason) if params[:topic_id].blank? group = find_group(:id) + if params[:topic_id] && topic = Topic.find_by_id(params[:topic_id]) + reason = I18n.t("groups.view_hidden_topic_request_reason", group_name: group.name, topic_url: topic.url) + end + + reason ||= params[:reason] + begin - GroupRequest.create!(group: group, user: current_user, reason: params[:reason]) + GroupRequest.create!(group: group, user: current_user, reason: reason) rescue ActiveRecord::RecordNotUnique => e return render json: failed_json.merge(error: I18n.t("groups.errors.already_requested_membership")), status: 409 end @@ -438,7 +444,7 @@ class GroupsController < ApplicationController ) raw = <<~EOF - #{params[:reason]} + #{reason} --- diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 1535359429e..74789f51d88 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -131,6 +131,9 @@ class TopicsController < ApplicationController perform_show_response rescue Discourse::InvalidAccess => ex + if !guardian.can_see_topic?(ex.obj) && guardian.can_get_access_to_topic?(ex.obj) + return perform_hidden_topic_show_response(ex.obj) + end if current_user # If the user can't see the topic, clean up notifications for it. @@ -950,6 +953,19 @@ class TopicsController < ApplicationController end end + def perform_hidden_topic_show_response(topic) + respond_to do |format| + format.html do + @topic_view = nil + render :show + end + + format.json do + render_serialized(topic, HiddenTopicViewSerializer, root: false) + end + end + end + def render_topic_changes(dest_topic) if dest_topic.present? render json: { success: true, url: dest_topic.relative_url } diff --git a/app/models/topic.rb b/app/models/topic.rb index c239c876c3d..648da51a741 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1384,6 +1384,15 @@ class Topic < ActiveRecord::Base end end + def access_topic_via_group + Group + .joins(:category_groups) + .where("category_groups.category_id = ?", self.category_id) + .where("groups.public_admission OR groups.allow_membership_requests") + .order(:allow_membership_requests) + .first + end + private def invite_to_private_message(invited_by, target_user, guardian) diff --git a/app/serializers/hidden_topic_view_serializer.rb b/app/serializers/hidden_topic_view_serializer.rb new file mode 100644 index 00000000000..414299f7e74 --- /dev/null +++ b/app/serializers/hidden_topic_view_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class HiddenTopicViewSerializer < ApplicationSerializer + attributes :view_hidden? + + has_one :group, serializer: BasicGroupSerializer, root: false, embed: :objects + + def view_hidden? + true + end + + def group + object.access_topic_via_group + end +end diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index 9d921c58bc7..3a2c86bd2f9 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -1,141 +1,143 @@ -

- <%= render_topic_title(@topic_view.topic) %> -

+<% if @topic_view %> +

+ <%= render_topic_title(@topic_view.topic) %> +

-<% @breadcrumbs = categories_breadcrumb(@topic_view.topic) - if @breadcrumbs.present? %> -
-<% end %> -<% if SiteSetting.tagging_enabled %> - <% @tags = @topic_view.topic.tags %> - <% if @tags.present? %> -
- <% @tags.each_with_index do |tag, i| %> -
- + <% if SiteSetting.tagging_enabled %> + <% @tags = @topic_view.topic.tags %> + <% if @tags.present? %> +
+ <% @tags.each_with_index do |tag, i| %> + + <% end %> +
+ <% end %> + <% end %> + + <%= server_plugin_outlet "topic_header" %> + + <%- if include_crawler_content? %> + + <% @topic_view.posts.each do |post| %> +
+ <% if (u = post.user) %> + +
+ <%= post.hidden ? t('flagging.user_must_edit').html_safe : post.cooked.html_safe %> +
+ + + +
+ + + <%= post.like_count > 0 ? t('post.has_likes', count: post.like_count) : '' %> +
+ +
+ + +
+ + <% if @topic_view.link_counts[post.id] && @topic_view.link_counts[post.id].length > 0 %> +
+ <% @topic_view.link_counts[post.id].each_with_index do |link, i| %> + <% if link[:reflection] && link[:title].present? %> + + <% end %> + <% end %> +
+ <% end %> + <% end %>
<% end %> -<% end %> - -<%= server_plugin_outlet "topic_header" %> - -<%- if include_crawler_content? %> - -<% @topic_view.posts.each do |post| %> -
- <% if (u = post.user) %> - -
- <%= post.hidden ? t('flagging.user_must_edit').html_safe : post.cooked.html_safe %> -
- - - -
- - - <%= post.like_count > 0 ? t('post.has_likes', count: post.like_count) : '' %> -
- -
- - -
- - <% if @topic_view.link_counts[post.id] && @topic_view.link_counts[post.id].length > 0 %> -
- <% @topic_view.link_counts[post.id].each_with_index do |link, i| %> - <% if link[:reflection] && link[:title].present? %> - - <% end %> - <% end %> -
- <% end %> - - <% end %> -
-<% end %> - -<% if @topic_view.prev_page || @topic_view.next_page %> - -<% end %> - -<% end %> - -<% content_for :head do %> - <%= auto_discovery_link_tag(@topic_view, {action: :feed, slug: @topic_view.topic.slug, topic_id: @topic_view.topic.id}, title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %> - <%= raw crawlable_meta_data(title: @topic_view.title, description: @topic_view.summary(strip_images: true), image: @topic_view.image_url, read_time: @topic_view.read_time, like_count: @topic_view.like_count, ignore_canonical: true, published_time: @topic_view.published_time) %> <% if @topic_view.prev_page || @topic_view.next_page %> - <% if @topic_view.prev_page %> - + + <% end %> + + <% end %> + + <% content_for :head do %> + <%= auto_discovery_link_tag(@topic_view, {action: :feed, slug: @topic_view.topic.slug, topic_id: @topic_view.topic.id}, title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %> + <%= raw crawlable_meta_data(title: @topic_view.title, description: @topic_view.summary(strip_images: true), image: @topic_view.image_url, read_time: @topic_view.read_time, like_count: @topic_view.like_count, ignore_canonical: true, published_time: @topic_view.published_time) %> + + <% if @topic_view.prev_page || @topic_view.next_page %> + <% if @topic_view.prev_page %> + + <% end %> + <% if @topic_view.next_page %> + + <% end %> <% end %> - <% if @topic_view.next_page %> - + <% end %> + + <% content_for(:title) { @title || "#{gsub_emoji_to_unicode(@topic_view.page_title)} - #{SiteSetting.title}" } %> + + <% if @topic_view.print %> + <% content_for :after_body do %> + <%= preload_script('print-page') %> <% end %> <% end %> <% end %> - -<% content_for(:title) { @title || "#{gsub_emoji_to_unicode(@topic_view.page_title)} - #{SiteSetting.title}" } %> - -<% if @topic_view.print %> - <% content_for :after_body do %> - <%= preload_script('print-page') %> - <% end %> -<% end %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index de60aa56f95..c5e2a11a309 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1967,6 +1967,9 @@ en: toggle_information: "toggle topic details" read_more_in_category: "Want to read more? Browse other topics in {{catLink}} or {{latestLink}}." read_more: "Want to read more? {{catLink}} or {{latestLink}}." + group_request: "You need to request membership to the `{{name}}` group to see this topic" + group_join: "You need join the `{{name}}` group to see this topic" + group_request_sent: "Your group membership request has been sent. You will be informed when it's accepted." # keys ending with _MF use message format, see https://meta.discourse.org/t/message-format-support-for-localization/7035 for details read_more_MF: "There { diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c3ba80e7d78..4941b627bc4 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -381,6 +381,7 @@ en: request_membership_pm: title: "Membership Request for @%{group_name}" handle: "handle membership request" + view_hidden_topic_request_reason: "I would like to join the group '%{group_name}', so I may access [this topic](%{topic_url})" education: until_posts: diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 97f2425ad02..e171d32d90f 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -159,6 +159,10 @@ module TopicGuardian can_see_topic?(topic, false) end + def can_get_access_to_topic?(topic) + topic&.access_topic_via_group.present? && authenticated? + end + def filter_allowed_categories(records) unless is_admin? allowed_ids = allowed_category_ids diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index cdb8b4ac7e9..26dda3fb468 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -2414,4 +2414,31 @@ describe Topic do expect { topic.reset_bumped_at }.not_to change { topic.bumped_at } end end + + describe "#access_topic_via_group" do + let(:open_group) { Fabricate(:group, public_admission: true) } + let(:request_group) do + Fabricate(:group).tap do |g| + g.add_owner(user) + g.allow_membership_requests = true + g.save! + end + end + let(:category) { Fabricate(:category) } + let(:topic) { Fabricate(:topic, category: category) } + + it "returns a group that is open or accepts membership requests and has access to the topic" do + expect(topic.access_topic_via_group).to eq(nil) + + category.set_permissions(request_group => :full) + category.save! + + expect(topic.access_topic_via_group).to eq(request_group) + + category.set_permissions(request_group => :full, open_group => :full) + category.save! + + expect(topic.access_topic_via_group).to eq(open_group) + end + end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 758bda7ce83..ad718e80f30 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1279,6 +1279,7 @@ RSpec.describe TopicsController do context 'permission errors' do fab!(:allowed_user) { Fabricate(:user) } let(:allowed_group) { Fabricate(:group) } + let(:accessible_group) { Fabricate(:group, public_admission: true) } let(:secure_category) do c = Fabricate(:category) c.permissions = [[allowed_group, :full]] @@ -1287,6 +1288,12 @@ RSpec.describe TopicsController do allowed_user.save c end + let(:accessible_category) do + Fabricate(:category).tap do |c| + c.set_permissions(accessible_group => :full) + c.save! + end + end let(:normal_topic) { Fabricate(:topic) } let(:secure_topic) { Fabricate(:topic, category: secure_category) } let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) } @@ -1294,6 +1301,7 @@ RSpec.describe TopicsController do let(:deleted_secure_topic) { Fabricate(:topic, category: secure_category, deleted_at: 1.day.ago) } let(:deleted_private_topic) { Fabricate(:private_message_topic, user: allowed_user, deleted_at: 1.day.ago) } let(:nonexist_topic_id) { Topic.last.id + 10000 } + let(:secure_accessible_topic) { Fabricate(:topic, category: accessible_category) } shared_examples "various scenarios" do |expected| expected.each do |key, value| @@ -1314,7 +1322,8 @@ RSpec.describe TopicsController do deleted_topic: 410, deleted_secure_topic: 403, deleted_private_topic: 403, - nonexist: 404 + nonexist: 404, + secure_accessible_topic: 403 } include_examples "various scenarios", expected end @@ -1330,7 +1339,8 @@ RSpec.describe TopicsController do deleted_topic: 302, deleted_secure_topic: 302, deleted_private_topic: 302, - nonexist: 302 + nonexist: 302, + secure_accessible_topic: 302 } include_examples "various scenarios", expected end @@ -1347,7 +1357,8 @@ RSpec.describe TopicsController do deleted_topic: 410, deleted_secure_topic: 403, deleted_private_topic: 403, - nonexist: 404 + nonexist: 404, + secure_accessible_topic: 200 } include_examples "various scenarios", expected end @@ -1364,7 +1375,8 @@ RSpec.describe TopicsController do deleted_topic: 410, deleted_secure_topic: 410, deleted_private_topic: 410, - nonexist: 404 + nonexist: 404, + secure_accessible_topic: 200 } include_examples "various scenarios", expected end @@ -1381,7 +1393,8 @@ RSpec.describe TopicsController do deleted_topic: 200, deleted_secure_topic: 403, deleted_private_topic: 403, - nonexist: 404 + nonexist: 404, + secure_accessible_topic: 200 } include_examples "various scenarios", expected end @@ -1398,7 +1411,8 @@ RSpec.describe TopicsController do deleted_topic: 200, deleted_secure_topic: 200, deleted_private_topic: 200, - nonexist: 404 + nonexist: 404, + secure_accessible_topic: 200 } include_examples "various scenarios", expected end