├── .editorconfig ├── LICENSE.md ├── README.md ├── composer.json ├── extend.php ├── js ├── admin.js ├── dist │ ├── admin.js │ ├── admin.js.map │ ├── forum.js │ └── forum.js.map ├── forum.js ├── package.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── migrations ├── 2019_06_11_000000_add_subscription_to_users_tags_table.php ├── 2019_06_28_000000_add_hide_subscription_option.php ├── 2021_01_22_000000_add_indicies.php └── 2022_05_20_000000_add_timestamps_to_tag_user_table.php ├── phpstan.neon ├── resources ├── less │ └── forum.less ├── locale │ └── en.yml └── views │ └── emails │ ├── newDiscussion.blade.php │ ├── newPost.blade.php │ └── newTag.blade.php ├── src ├── AddTagSubscriptionAttribute.php ├── Controllers │ └── ChangeTagSubscription.php ├── Data │ └── TagSubscription.php ├── Event │ ├── SubscriptionChanged.php │ └── SubscriptionChanging.php ├── Jobs │ ├── NotificationJob.php │ ├── SendNotificationWhenDiscussionIsReTagged.php │ ├── SendNotificationWhenDiscussionIsStarted.php │ └── SendNotificationWhenReplyIsPosted.php ├── Listeners │ ├── DeleteNotificationWhenDiscussionIsHiddenOrDeleted.php │ ├── DeleteNotificationWhenPostIsHiddenOrDeleted.php │ ├── PreventMentionNotificationsFromIgnoredTags.php │ ├── QueueNotificationJobs.php │ ├── RestoreNotificationWhenDiscussionIsRestored.php │ └── RestoreNotificationWhenPostIsRestored.php ├── Notifications │ ├── NewDiscussionBlueprint.php │ ├── NewDiscussionTagBlueprint.php │ └── NewPostBlueprint.php └── Search │ ├── FollowTagsFilter.php │ └── HideTagsFilter.php └── tests ├── integration ├── ExtensionDepsTrait.php ├── TagsDefinitionTrait.php ├── notifications │ ├── NotificationsCountTest.php │ └── NotificationsTest.php └── setup.php ├── phpunit.integration.xml └── phpunit.unit.xml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | end_of_line = lf 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | [*.md] 10 | indent_size = 2 11 | trim_trailing_whitespace = false 12 | [*.less] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 FriendsOfFlarum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Follow Tags by FriendsOfFlarum 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/fof/follow-tags.svg)](https://packagist.org/packages/fof/follow-tags) [![OpenCollective](https://img.shields.io/badge/opencollective-fof-blue.svg)](https://opencollective.com/fof/donate) [![Donate](https://img.shields.io/badge/donate-datitisev-important.svg)](https://datitisev.me/donate) 4 | 5 | A [Flarum](http://flarum.org) extension. Follow tags and be notified of new discussions. 6 | 7 |
8 | Screenshots 9 | 10 | share modal 11 |
12 | 13 | ### Installation 14 | 15 | Install with composer: 16 | 17 | ```sh 18 | composer require fof/follow-tags:"*" 19 | ``` 20 | 21 | ### Updating 22 | 23 | ```sh 24 | composer update fof/follow-tags:"*" 25 | ``` 26 | 27 | ### Links 28 | 29 | [![OpenCollective](https://img.shields.io/badge/donate-friendsofflarum-44AEE5?style=for-the-badge&logo=open-collective)](https://opencollective.com/fof/donate) [![GitHub](https://img.shields.io/badge/donate-datitisev-ea4aaa?style=for-the-badge&logo=github)](https://datitisev.me/donate/github) 30 | 31 | - [Discuss](https://discuss.flarum.org/d/20525) 32 | - [Packagist](https://packagist.org/packages/fof/follow-tags) 33 | - [GitHub](https://github.com/packages/FriendsOfFlarum/follow-tags) 34 | 35 | An extension by [FriendsOfFlarum](https://github.com/FriendsOfFlarum). 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fof/follow-tags", 3 | "description": "Follow tags and be notified of new discussions and replies", 4 | "keywords": [ 5 | "flarum" 6 | ], 7 | "type": "flarum-extension", 8 | "license": "MIT", 9 | "support": { 10 | "issues": "https://github.com/FriendsOfFlarum/follow-tags/issues", 11 | "source": "https://github.com/FriendsOfFlarum/follow-tags", 12 | "forum": "https://discuss.flarum.org/d/20525" 13 | }, 14 | "homepage": "https://friendsofflarum.org", 15 | "funding": [ 16 | { 17 | "type": "website", 18 | "url": "https://opencollective.com/fof/donate" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.0", 23 | "flarum/core": "^1.8.3", 24 | "flarum/tags": "^1.8.0", 25 | "fof/extend": "^1.2.0" 26 | }, 27 | "authors": [ 28 | { 29 | "name": "David Sevilla Martín", 30 | "email": "me+fof@datitisev.me", 31 | "role": "Developer" 32 | }, 33 | { 34 | "name": "IanM", 35 | "email": "ian@flarum.org", 36 | "role": "Developer" 37 | } 38 | ], 39 | "autoload": { 40 | "psr-4": { 41 | "FoF\\FollowTags\\": "src/" 42 | } 43 | }, 44 | "extra": { 45 | "flarum-extension": { 46 | "title": "FoF Follow Tags", 47 | "category": "feature", 48 | "icon": { 49 | "name": "fas fa-user-tag", 50 | "backgroundColor": "#e74c3c", 51 | "color": "#fff" 52 | }, 53 | "optional-dependencies": [ 54 | "flarum/subscriptions", 55 | "flarum/approval", 56 | "flarum/mentions", 57 | "flarum/gdpr" 58 | ] 59 | }, 60 | "flagrow": { 61 | "discuss": "https://discuss.flarum.org/d/20525" 62 | }, 63 | "flarum-cli": { 64 | "modules": { 65 | "githubActions": true 66 | } 67 | } 68 | }, 69 | "require-dev": { 70 | "flarum/approval": "*", 71 | "flarum/subscriptions": "*", 72 | "flarum/phpstan": "*", 73 | "flarum/mentions": "*", 74 | "flarum/gdpr": "dev-main", 75 | "flarum/testing": "^1.0.0", 76 | "flarum/tags":"*", 77 | "fof/extend": "*" 78 | }, 79 | "autoload-dev": { 80 | "psr-4": { 81 | "FoF\\FollowTags\\Tests\\": "tests/" 82 | } 83 | }, 84 | "scripts": { 85 | "analyse:phpstan": "phpstan analyse", 86 | "clear-cache:phpstan": "phpstan clear-result-cache", 87 | "test": [ 88 | "@test:unit", 89 | "@test:integration" 90 | ], 91 | "test:unit": "phpunit -c tests/phpunit.unit.xml", 92 | "test:integration": "phpunit -c tests/phpunit.integration.xml", 93 | "test:setup": "@php tests/integration/setup.php" 94 | }, 95 | "scripts-descriptions": { 96 | "analyse:phpstan": "Run static analysis", 97 | "test": "Runs all tests.", 98 | "test:unit": "Runs all unit tests.", 99 | "test:integration": "Runs all integration tests.", 100 | "test:setup": "Sets up a database for use with integration tests. Execute this only once." 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /extend.php: -------------------------------------------------------------------------------- 1 | js(__DIR__.'/js/dist/forum.js') 27 | ->css(__DIR__.'/resources/less/forum.less'), 28 | 29 | (new Extend\Frontend('admin')) 30 | ->js(__DIR__.'/js/dist/admin.js'), 31 | 32 | new Extend\Locales(__DIR__.'/resources/locale'), 33 | 34 | (new Extend\Model(TagState::class)) 35 | ->cast('subscription', 'string'), 36 | 37 | (new Extend\Routes('api')) 38 | ->post('/tags/{id}/subscription', 'fof-follow-tags.subscription', Controllers\ChangeTagSubscription::class), 39 | 40 | (new Extend\View()) 41 | ->namespace('fof-follow-tags', __DIR__.'/resources/views'), 42 | 43 | (new ExtensionSettings()) 44 | ->addKey('fof-follow-tags.following_page_default'), 45 | 46 | (new Extend\Event()) 47 | ->listen(Discussion\Deleted::class, Listeners\DeleteNotificationWhenDiscussionIsHiddenOrDeleted::class) 48 | ->listen(Discussion\Hidden::class, Listeners\DeleteNotificationWhenDiscussionIsHiddenOrDeleted::class) 49 | ->listen(Discussion\Restored::class, Listeners\RestoreNotificationWhenDiscussionIsRestored::class) 50 | ->listen(Post\Hidden::class, Listeners\DeleteNotificationWhenPostIsHiddenOrDeleted::class) 51 | ->listen(Post\Deleted::class, Listeners\DeleteNotificationWhenPostIsHiddenOrDeleted::class) 52 | ->listen(Post\Restored::class, Listeners\RestoreNotificationWhenPostIsRestored::class) 53 | ->subscribe(Listeners\QueueNotificationJobs::class), 54 | 55 | (new Extend\Filter(DiscussionFilterer::class)) 56 | ->addFilter(Search\FollowTagsFilter::class) 57 | ->addFilterMutator(Search\HideTagsFilter::class), 58 | 59 | (new Extend\User()) 60 | ->registerPreference('followTagsPageDefault'), 61 | 62 | (new Extend\ApiSerializer(TagSerializer::class)) 63 | ->attributes(AddTagSubscriptionAttribute::class), 64 | 65 | (new Extend\Notification()) 66 | ->type(Notifications\NewDiscussionBlueprint::class, DiscussionSerializer::class, ['alert', 'email']) 67 | ->type(Notifications\NewPostBlueprint::class, DiscussionSerializer::class, ['alert', 'email']) 68 | ->type(Notifications\NewDiscussionTagBlueprint::class, DiscussionSerializer::class, ['alert', 'email']) 69 | ->beforeSending(Listeners\PreventMentionNotificationsFromIgnoredTags::class), 70 | 71 | (new Extend\Conditional()) 72 | ->whenExtensionEnabled('flarum-gdpr', fn () => [ 73 | (new UserData()) 74 | ->addType(Data\TagSubscription::class), 75 | ]), 76 | ]; 77 | -------------------------------------------------------------------------------- /js/admin.js: -------------------------------------------------------------------------------- 1 | export * from './src/admin'; 2 | export * from './src/common'; 3 | -------------------------------------------------------------------------------- /js/dist/admin.js: -------------------------------------------------------------------------------- 1 | (()=>{var e={n:o=>{var t=o&&o.__esModule?()=>o.default:()=>o;return e.d(t,{a:t}),t},d:(o,t)=>{for(var n in t)e.o(t,n)&&!e.o(o,n)&&Object.defineProperty(o,n,{enumerable:!0,get:t[n]})},o:(e,o)=>Object.prototype.hasOwnProperty.call(e,o),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},o={};(()=>{"use strict";e.r(o),e.d(o,{utils:()=>i});const t=flarum.core.compat["admin/app"];var n=e.n(t);const a=flarum.core.compat["common/app"];var l,r=e.n(a);const f=function(e){return l||(l=["none","tags"].reduce((function(o,t){return o[t]=r().translator.trans("fof-follow-tags."+e+".following_"+t+"_label"),o}),{}))};n().initializers.add("fof/follow-tags",(function(){n().extensionData.for("fof-follow-tags").registerSetting({setting:"fof-follow-tags.following_page_default",options:f("admin.settings"),type:"select",label:n().translator.trans("fof-follow-tags.admin.settings.following_page_default_label"),default:"none",required:!0})}));var i={followingPageOptions:f}})(),module.exports=o})(); 2 | //# sourceMappingURL=admin.js.map -------------------------------------------------------------------------------- /js/dist/admin.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"admin.js","mappings":"MACA,IAAIA,EAAsB,CCA1BA,EAAyBC,IACxB,IAAIC,EAASD,GAAUA,EAAOE,WAC7B,IAAOF,EAAiB,QACxB,IAAM,EAEP,OADAD,EAAoBI,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,CAAM,ECLdF,EAAwB,CAACM,EAASC,KACjC,IAAI,IAAIC,KAAOD,EACXP,EAAoBS,EAAEF,EAAYC,KAASR,EAAoBS,EAAEH,EAASE,IAC5EE,OAAOC,eAAeL,EAASE,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECNDR,EAAwB,CAACc,EAAKC,IAAUL,OAAOM,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFf,EAAyBM,IACH,oBAAXa,QAA0BA,OAAOC,aAC1CV,OAAOC,eAAeL,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DX,OAAOC,eAAeL,EAAS,aAAc,CAAEe,OAAO,GAAO,G,oDCL9D,MAAM,EAA+BC,OAAOC,KAAKC,OAAO,a,aCAxD,MAAM,EAA+BF,OAAOC,KAAKC,OAAO,c,ICEpDC,E,SAEJ,iBAAgBC,GAAD,OACbD,IACCA,EAAO,CAAC,OAAQ,QAAQE,QAAO,SAAClB,EAAGD,GAGlC,OAFAC,EAAED,GAAOoB,IAAAA,WAAeC,MAAf,mBAAwCH,EAAxC,cAA6DlB,EAA7D,UAEFC,CACR,GAAE,CAAC,GANN,ECDAmB,IAAAA,aAAiBE,IAAI,mBAAmB,WACtCF,IAAAA,cAAA,IAAsB,mBAAmBG,gBAAgB,CACvDC,QAAS,yCACTC,QAASC,EAAqB,kBAC9BC,KAAM,SACNC,MAAOR,IAAAA,WAAeC,MAAM,+DAC5BQ,QAAS,OACTC,UAAU,GAEb,ICVM,IAAMC,EAAQ,CACnBL,qBAAAA,E","sources":["webpack://@fof/follow-tags/webpack/bootstrap","webpack://@fof/follow-tags/webpack/runtime/compat get default export","webpack://@fof/follow-tags/webpack/runtime/define property getters","webpack://@fof/follow-tags/webpack/runtime/hasOwnProperty shorthand","webpack://@fof/follow-tags/webpack/runtime/make namespace object","webpack://@fof/follow-tags/external root \"flarum.core.compat['admin/app']\"","webpack://@fof/follow-tags/external root \"flarum.core.compat['common/app']\"","webpack://@fof/follow-tags/./src/common/utils/followingPageOptions.js","webpack://@fof/follow-tags/./src/admin/index.ts","webpack://@fof/follow-tags/./src/common/index.ts"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['admin/app'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/app'];","import app from 'flarum/common/app';\n\nlet opts;\n\nexport default (section) =>\n opts ||\n (opts = ['none', 'tags'].reduce((o, key) => {\n o[key] = app.translator.trans(`fof-follow-tags.${section}.following_${key}_label`);\n\n return o;\n }, {}));\n","import app from 'flarum/admin/app';\nimport followingPageOptions from '../common/utils/followingPageOptions';\n\napp.initializers.add('fof/follow-tags', () => {\n app.extensionData.for('fof-follow-tags').registerSetting({\n setting: 'fof-follow-tags.following_page_default',\n options: followingPageOptions('admin.settings'),\n type: 'select',\n label: app.translator.trans('fof-follow-tags.admin.settings.following_page_default_label'),\n default: 'none',\n required: true,\n });\n});\n","import followingPageOptions from './utils/followingPageOptions';\n\nexport const utils = {\n followingPageOptions,\n};\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","opts","section","reduce","app","trans","add","registerSetting","setting","options","followingPageOptions","type","label","default","required","utils"],"sourceRoot":""} -------------------------------------------------------------------------------- /js/dist/forum.js: -------------------------------------------------------------------------------- 1 | (()=>{var t={n:o=>{var n=o&&o.__esModule?()=>o.default:()=>o;return t.d(n,{a:n}),n},d:(o,n)=>{for(var s in n)t.o(n,s)&&!t.o(o,s)&&Object.defineProperty(o,s,{enumerable:!0,get:n[s]})},o:(t,o)=>Object.prototype.hasOwnProperty.call(t,o),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},o={};(()=>{"use strict";t.r(o),t.d(o,{components:()=>bt,utils:()=>et});const n=flarum.core.compat["forum/app"];var s=t.n(n);const r=flarum.core.compat["common/Model"];var i=t.n(r);const e=flarum.core.compat["common/extend"],a=flarum.core.compat["forum/components/IndexPage"];var l=t.n(a);const c=flarum.core.compat["forum/states/DiscussionListState"];var u=t.n(c);const f=function(){return"flarum-subscriptions"in flarum.extensions&&m.route.get().includes(s().route("following"))},p=flarum.core.compat["common/app"];var g,d=t.n(p);const b=function(t){return g||(g=["none","tags"].reduce((function(o,n){return o[n]=d().translator.trans("fof-follow-tags."+t+".following_"+n+"_label"),o}),{}))};var h,w=function(){return h||(h=b("forum.index.following")),h},v=function(){w();var t=s().data["fof-follow-tags.following_page_default"];if(h[t]||(t=null),s().session.user){var o=s().session.user.preferences().followTagsPageDefault;h[o]&&(t=o)}return t||"none"};function y(t,o){return y=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,o){return t.__proto__=o,t},y(t,o)}function _(t,o){t.prototype=Object.create(o.prototype),t.prototype.constructor=t,y(t,o)}const T=flarum.core.compat["common/Component"];var S=t.n(T);const N=flarum.core.compat["common/components/Button"];var O=t.n(N);const P=flarum.core.compat["common/components/Dropdown"];var I=t.n(P),x=function(t){function o(){return t.apply(this,arguments)||this}_(o,t);var n=o.prototype;return n.view=function(){var t=s().discussions.followTags,o=this.options();return I().component({buttonClassName:"Button",label:o[t]||v()},Object.keys(o).map((function(n){var r=n===t;return O().component({active:r,icon:!r||"fas fa-check",onclick:function(){s().discussions.followTags=n,s().discussions.refresh()}},o[n])})))},n.options=function(){return w()},o}(S());const D=flarum.core.compat["forum/components/Notification"];var j,k=t.n(D),K=function(t){function o(){return t.apply(this,arguments)||this}_(o,t);var n=o.prototype;return n.icon=function(){return"fas fa-user-tag"},n.href=function(){var t=this.attrs.notification.subject();return s().route.discussion(t)},n.content=function(){return s().translator.trans("fof-follow-tags.forum.notifications.new_discussion_text",{user:this.attrs.notification.fromUser(),title:this.attrs.notification.subject().title()})},n.excerpt=function(){return null},o}(k());const F=((j={})[!1]="fas fa-star",j.follow="fas fa-star",j.lurk="fas fa-comments",j.ignore="fas fa-bell-slash",j.hide="fas fa-eye-slash",j);var B=function(t){function o(){return t.apply(this,arguments)||this}_(o,t);var n=o.prototype;return n.icon=function(){return F.lurk},n.href=function(){var t=this.attrs.notification,o=t.subject(),n=t.content()||{};return s().route.discussion(o,n.postNumber)},n.content=function(){return s().translator.trans("fof-follow-tags.forum.notifications.new_post_text",{user:this.attrs.notification.fromUser()})},n.excerpt=function(){return null},o}(k()),C=function(t){function o(){return t.apply(this,arguments)||this}_(o,t);var n=o.prototype;return n.icon=function(){return"fas fa-user-tag"},n.href=function(){var t=this.attrs.notification.subject();return s().route.discussion(t)},n.content=function(){return s().translator.trans("fof-follow-tags.forum.notifications.new_discussion_tag_text",{user:this.attrs.notification.fromUser(),title:this.attrs.notification.subject().title()})},n.excerpt=function(){return null},o}(k());const M=flarum.core.compat["common/models/Discussion"];var A=t.n(M);const H=flarum.core.compat["common/components/Badge"];var q=t.n(H);const U=flarum.core.compat["forum/components/SettingsPage"];var L=t.n(U);const z=flarum.core.compat["common/components/FieldSet"];var E=t.n(z);const G=flarum.core.compat["common/components/Select"];var V=t.n(G);const J=flarum.core.compat["forum/components/NotificationGrid"];var Q=t.n(J);const R=flarum.core.compat["common/helpers/icon"];var W=t.n(R);const X=flarum.core.compat["common/helpers/textContrastClass"];var Y=t.n(X);const Z=flarum.core.compat["common/utils/classList"];var $=t.n(Z);const tt=flarum.core.compat["common/utils/ItemList"];var ot=t.n(tt),nt=function(t){function o(){return t.apply(this,arguments)||this}_(o,t);var n=o.prototype;return n.view=function(){var t,o=this.heroColor();return m("header",{className:$()("Hero","FollowingHero",(t={"FollowingHero--colored":o},t[Y()(o)]=o,t)),style:o?{"--hero-bg":o}:void 0},m("div",{className:"container"},this.viewItems().toArray()))},n.viewItems=function(){var t=new(ot());return t.add("content",m("div",{className:"containerNarrow"},this.contentItems().toArray()),80),t},n.contentItems=function(){var t=new(ot());return t.add("following-title",m("h1",{className:"Hero-title"},W()(this.heroIcon())," ",s().translator.trans("fof-follow-tags.forum.hero.title")),100),t},n.heroColor=function(){return null},n.heroIcon=function(){return"fas fa-star"},o}(S());function st(){return st=Object.assign?Object.assign.bind():function(t){for(var o=1;o {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/app'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/Model'];","import app from 'flarum/forum/app';\nimport Model from 'flarum/common/Model';\n\nexport default () => {\n app.store.models.tags.prototype.subscription = Model.attribute('subscription');\n};\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/extend'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/components/IndexPage'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/states/DiscussionListState'];","import app from 'flarum/forum/app';\n\nexport default () => 'flarum-subscriptions' in flarum.extensions && m.route.get().includes(app.route('following'));\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/app'];","import app from 'flarum/common/app';\n\nlet opts;\n\nexport default (section) =>\n opts ||\n (opts = ['none', 'tags'].reduce((o, key) => {\n o[key] = app.translator.trans(`fof-follow-tags.${section}.following_${key}_label`);\n\n return o;\n }, {}));\n","import app from 'flarum/forum/app';\nimport followingPageOptions from '../../common/utils/followingPageOptions';\n\nexport let options;\n\nexport const getOptions = () => {\n if (!options) {\n options = followingPageOptions('forum.index.following');\n }\n\n return options;\n};\n\nexport const getDefaultFollowingFiltering = () => {\n getOptions();\n\n let value = app.data['fof-follow-tags.following_page_default'];\n\n if (!options[value]) {\n value = null;\n }\n\n if (app.session.user) {\n const preference = app.session.user.preferences().followTagsPageDefault;\n\n if (options[preference]) {\n value = preference;\n }\n }\n\n return value || 'none';\n};\n","export default function _setPrototypeOf(o, p) {\n _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) {\n o.__proto__ = p;\n return o;\n };\n return _setPrototypeOf(o, p);\n}","import setPrototypeOf from \"./setPrototypeOf.js\";\nexport default function _inheritsLoose(subClass, superClass) {\n subClass.prototype = Object.create(superClass.prototype);\n subClass.prototype.constructor = subClass;\n setPrototypeOf(subClass, superClass);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/Component'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Button'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Dropdown'];","import app from 'flarum/forum/app';\nimport Component from 'flarum/common/Component';\n\nimport Button from 'flarum/common/components/Button';\nimport Dropdown from 'flarum/common/components/Dropdown';\n\nimport { getDefaultFollowingFiltering, getOptions } from '../utils/getDefaultFollowingFiltering';\n\nexport default class FollowingPageFilterDropdown extends Component {\n view() {\n const selected = app.discussions.followTags;\n const options = this.options();\n\n return Dropdown.component(\n {\n buttonClassName: 'Button',\n label: options[selected] || getDefaultFollowingFiltering(),\n },\n Object.keys(options).map((key) => {\n const active = key === selected;\n\n return Button.component(\n {\n active,\n icon: active ? 'fas fa-check' : true,\n onclick: () => {\n app.discussions.followTags = key;\n\n app.discussions.refresh();\n },\n },\n options[key]\n );\n })\n );\n }\n\n options() {\n return getOptions();\n }\n}\n","import app from 'flarum/forum/app';\nimport { extend } from 'flarum/common/extend';\n\nimport IndexPage from 'flarum/forum/components/IndexPage';\nimport DiscussionListState from 'flarum/forum/states/DiscussionListState';\n\nimport isFollowingPage from './utils/isFollowingPage';\n\nimport { getDefaultFollowingFiltering } from './utils/getDefaultFollowingFiltering';\nimport FollowingPageFilterDropdown from './components/FollowingPageFilterDropdown';\n\nexport default () => {\n extend(DiscussionListState.prototype, 'requestParams', function (params) {\n if (!isFollowingPage() || !app.session.user) return;\n\n if (!this.followTags) {\n this.followTags = getDefaultFollowingFiltering();\n }\n\n const followTags = this.followTags;\n\n if (app.current.get('routeName') === 'following' && followTags === 'tags') {\n params.filter['following-tag'] = true;\n\n delete params.filter.subscription;\n }\n });\n\n extend(IndexPage.prototype, 'viewItems', function (items) {\n if (!isFollowingPage() || !app.session.user) {\n return;\n }\n\n items.add('follow-tags', );\n });\n};\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/components/Notification'];","import app from 'flarum/forum/app';\nimport Notification from 'flarum/forum/components/Notification';\n\nexport default class NewDiscussionNotification extends Notification {\n icon() {\n return 'fas fa-user-tag';\n }\n\n href() {\n const notification = this.attrs.notification;\n const discussion = notification.subject();\n\n return app.route.discussion(discussion);\n }\n\n content() {\n return app.translator.trans('fof-follow-tags.forum.notifications.new_discussion_text', {\n user: this.attrs.notification.fromUser(),\n title: this.attrs.notification.subject().title(),\n });\n }\n\n excerpt() {\n return null;\n }\n}\n","export default {\n [false]: 'fas fa-star',\n follow: 'fas fa-star',\n lurk: 'fas fa-comments',\n ignore: 'fas fa-bell-slash',\n hide: 'fas fa-eye-slash',\n};\n","import app from 'flarum/forum/app';\nimport Notification from 'flarum/forum/components/Notification';\n\nimport icons from '../icons';\n\nexport default class NewPostNotification extends Notification {\n icon() {\n return icons.lurk;\n }\n\n href() {\n const notification = this.attrs.notification;\n const discussion = notification.subject();\n const content = notification.content() || {};\n\n return app.route.discussion(discussion, content.postNumber);\n }\n\n content() {\n return app.translator.trans('fof-follow-tags.forum.notifications.new_post_text', { user: this.attrs.notification.fromUser() });\n }\n\n excerpt() {\n return null;\n }\n}\n","import app from 'flarum/forum/app';\nimport Notification from 'flarum/forum/components/Notification';\n\nexport default class NewDiscussionTagNotification extends Notification {\n icon() {\n return 'fas fa-user-tag';\n }\n\n href() {\n const notification = this.attrs.notification;\n const discussion = notification.subject();\n\n return app.route.discussion(discussion);\n }\n\n content() {\n return app.translator.trans('fof-follow-tags.forum.notifications.new_discussion_tag_text', {\n user: this.attrs.notification.fromUser(),\n title: this.attrs.notification.subject().title(),\n });\n }\n\n excerpt() {\n return null;\n }\n}\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/models/Discussion'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Badge'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/components/SettingsPage'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/FieldSet'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Select'];","import app from 'flarum/forum/app';\nimport { extend } from 'flarum/common/extend';\n\nimport SettingsPage from 'flarum/forum/components/SettingsPage';\nimport FieldSet from 'flarum/common/components/FieldSet';\nimport Select from 'flarum/common/components/Select';\n\nimport { getOptions, getDefaultFollowingFiltering } from './utils/getDefaultFollowingFiltering';\n\nexport default () => {\n extend(SettingsPage.prototype, 'settingsItems', function (items) {\n items.add(\n 'fof-follow-tags',\n FieldSet.component(\n {\n label: app.translator.trans('fof-follow-tags.forum.user.settings.heading'),\n className: 'Settings-follow-tags',\n },\n [\n
\n

{app.translator.trans('fof-follow-tags.forum.user.settings.filter_label')}

\n {Select.component({\n options: getOptions(),\n value: this.user.preferences().followTagsPageDefault || getDefaultFollowingFiltering(),\n onchange: (value) => {\n this.user.savePreferences({ followTagsPageDefault: value }).then(() => {\n m.redraw();\n });\n },\n })}\n
,\n ]\n )\n );\n });\n};\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['forum/components/NotificationGrid'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/helpers/icon'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/helpers/textContrastClass'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/classList'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/ItemList'];","import app from 'flarum/forum/app';\nimport Component from 'flarum/common/Component';\nimport icon from 'flarum/common/helpers/icon';\nimport textContrastClass from 'flarum/common/helpers/textContrastClass';\nimport classList from 'flarum/common/utils/classList';\nimport ItemList from 'flarum/common/utils/ItemList';\nimport type Mithril from 'mithril';\n\nexport default class FollowingHero extends Component {\n view() {\n const color = this.heroColor();\n\n return (\n \n
{this.viewItems().toArray()}
\n \n );\n }\n\n viewItems(): ItemList {\n const items = new ItemList();\n\n items.add('content',
{this.contentItems().toArray()}
, 80);\n\n return items;\n }\n\n contentItems(): ItemList {\n const items = new ItemList();\n\n items.add(\n 'following-title',\n

\n {icon(this.heroIcon())} {app.translator.trans('fof-follow-tags.forum.hero.title')}\n

,\n 100\n );\n\n return items;\n }\n\n heroColor(): string | null {\n // Example return a color string to display a colored hero\n //return app.forum.attribute('themeSecondaryColor');\n return null;\n }\n\n heroIcon(): string {\n return 'fas fa-star';\n }\n}\n","export default function _extends() {\n _extends = Object.assign ? Object.assign.bind() : function (target) {\n for (var i = 1; i < arguments.length; i++) {\n var source = arguments[i];\n\n for (var key in source) {\n if (Object.prototype.hasOwnProperty.call(source, key)) {\n target[key] = source[key];\n }\n }\n }\n\n return target;\n };\n return _extends.apply(this, arguments);\n}","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Modal'];","import { options, getOptions, getDefaultFollowingFiltering } from './getDefaultFollowingFiltering';\nimport isFollowingPage from './isFollowingPage';\nimport subscriptionOptions from './subscriptionOptions';\nimport { utils as commonUtils } from '../../common';\n\nexport const utils = {\n options,\n getOptions,\n getDefaultFollowingFiltering,\n isFollowingPage,\n subscriptionOptions,\n ...commonUtils,\n};\n","type SubscriptionOption = {\n subscription: string;\n icon: string;\n labelKey: string;\n descriptionKey: string;\n};\n\nconst subscriptionOptions: SubscriptionOption[] = [\n {\n subscription: 'not_follow',\n icon: 'far fa-star',\n labelKey: 'fof-follow-tags.forum.sub_controls.not_following_button',\n descriptionKey: 'fof-follow-tags.forum.sub_controls.not_following_text',\n },\n {\n subscription: 'follow',\n icon: 'fas fa-star',\n labelKey: 'fof-follow-tags.forum.sub_controls.following_button',\n descriptionKey: 'fof-follow-tags.forum.sub_controls.following_text',\n },\n {\n subscription: 'lurk',\n icon: 'fas fa-eye',\n labelKey: 'fof-follow-tags.forum.sub_controls.lurking_button',\n descriptionKey: 'fof-follow-tags.forum.sub_controls.lurking_text',\n },\n {\n subscription: 'ignore',\n icon: 'fas fa-bell-slash',\n labelKey: 'fof-follow-tags.forum.sub_controls.ignoring_button',\n descriptionKey: 'fof-follow-tags.forum.sub_controls.ignoring_text',\n },\n {\n subscription: 'hide',\n icon: 'fas fa-eye-slash',\n labelKey: 'fof-follow-tags.forum.sub_controls.hiding_button',\n descriptionKey: 'fof-follow-tags.forum.sub_controls.hiding_text',\n },\n];\n\nexport default subscriptionOptions;\n","import followingPageOptions from './utils/followingPageOptions';\n\nexport const utils = {\n followingPageOptions,\n};\n","import app from 'flarum/forum/app';\nimport icon from 'flarum/common/helpers/icon';\nimport Component, { ComponentAttrs } from 'flarum/common/Component';\n\ninterface SubscriptionOptionItemAttrs extends ComponentAttrs {\n active: boolean;\n icon: string;\n labelKey: string;\n descriptionKey: string;\n onclick: () => void;\n}\n\nexport default class SubscriptionOptionItem extends Component {\n view() {\n const isSelected = this.attrs.active;\n\n return (\n
\n {icon(this.attrs.icon, { className: 'SubscriptionOption-icon' })}\n {app.translator.trans(this.attrs.labelKey)}\n
{app.translator.trans(this.attrs.descriptionKey)}
\n {isSelected && icon('fas fa-check', { className: 'SubscriptionOption-selectedIcon' })}\n
\n );\n }\n}\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/Stream'];","import app from 'flarum/forum/app';\nimport Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';\nimport Button from 'flarum/common/components/Button';\nimport ItemList from 'flarum/common/utils/ItemList';\nimport { utils } from '../utils';\nimport SubscriptionOptionItem from './SubscriptionOptionItem';\nimport type Mithril from 'mithril';\nimport Tag from 'flarum/tags/models/Tag';\nimport Stream from 'flarum/common/utils/Stream';\nimport Tooltip from 'flarum/common/components/Tooltip';\n\ninterface ISubscriptionModalAttrs extends IInternalModalAttrs {\n model?: Tag;\n}\n\nexport default class SubscriptionModal extends Modal {\n subscription!: string;\n loading: Stream;\n canShowTooltip: boolean | undefined = undefined;\n\n oninit(vnode: Mithril.Vnode): void {\n super.oninit(vnode);\n this.loading = Stream(false);\n this.subscription = this.attrs.model.subscription() || 'not_follow';\n\n const preferences = app.session.user?.preferences();\n const notifyEmail = preferences['notify_newPostInTag_email'];\n const notifyAlert = preferences['notify_newPostInTag_alert'];\n\n if ((notifyEmail || notifyAlert) && this.subscription === 'not_follow') {\n this.canShowTooltip = true;\n } else {\n this.canShowTooltip = false;\n }\n }\n\n className() {\n return 'SubscriptionModal Modal--medium';\n }\n\n title() {\n return app.translator.trans('fof-follow-tags.forum.sub_controls.header', {\n tagName: this.attrs.model.name(),\n });\n }\n\n content() {\n const preferences = app.session.user?.preferences();\n const notifyEmail = preferences['notify_newPostInTag_email'];\n const notifyAlert = preferences['notify_newPostInTag_alert'];\n\n return (\n
\n {this.formOptionItems().toArray()}\n
\n \n
\n
\n );\n }\n\n formOptionItems(): ItemList {\n const items = new ItemList();\n\n items.add(\n 'subscription_type',\n
\n \n {this.subscriptionOptionItems().toArray()}\n
,\n 60\n );\n\n return items;\n }\n\n subscriptionOptionItems(): ItemList {\n const items = new ItemList();\n let priority = 100;\n\n utils.subscriptionOptions.forEach((option, index) => {\n const attrs = {\n ...option,\n onclick: () => {\n this.subscription = option.subscription;\n this.canShowTooltip = false;\n },\n active: this.subscription === option.subscription,\n disabled: option.subscription === 'hide' && this.attrs.model.isHidden(),\n };\n\n items.add(`subscription-option-${index}`, , priority);\n priority -= 5;\n });\n\n return items;\n }\n\n saveSubscription(subscription: string) {\n const tag = this.attrs.model;\n\n this.loading(true);\n\n this.subscription = subscription;\n\n app\n .request({\n url: `${app.forum.attribute('apiUrl')}/tags/${tag.id()}/subscription`,\n method: 'POST',\n body: {\n data: this.requestData(),\n },\n })\n .then((res: any) => app.store.pushPayload(res))\n .then(() => {\n this.loading(false);\n\n m.redraw();\n this.hide();\n });\n }\n\n requestData(): { [key: string]: string } {\n return { subscription: this.subscription };\n }\n}\n","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/components/Tooltip'];","const __WEBPACK_NAMESPACE_OBJECT__ = flarum.core.compat['common/utils/extractText'];","import app from 'flarum/forum/app';\nimport Button from 'flarum/common/components/Button';\nimport { utils } from '../utils';\nimport Tooltip from 'flarum/common/components/Tooltip';\nimport extractText from 'flarum/common/utils/extractText';\nimport Stream from 'flarum/common/utils/Stream';\n\nexport default class SubscriptionStateButton extends Button {\n oninit(vnode) {\n super.oninit(vnode);\n\n this.loading = Stream(false);\n this.canShowTooltip = Stream(false);\n }\n\n onbeforeupdate(vnode) {\n super.onbeforeupdate(vnode);\n\n const subscription = this.attrs.subscription || false;\n\n const preferences = app.session.user.preferences();\n const notifyEmail = preferences['notify_newPostInTag_email'];\n const notifyAlert = preferences['notify_newPostInTag_alert'];\n\n if ((notifyEmail || notifyAlert) && subscription === false) {\n this.canShowTooltip(undefined);\n } else {\n this.canShowTooltip(false);\n }\n }\n\n view(vnode) {\n const subscription = this.attrs.subscription || false;\n let option = utils.subscriptionOptions.find((opt) => opt.subscription === subscription);\n\n let buttonIcon = option ? option.icon : 'fas fa-star';\n let buttonLabel = option ? app.translator.trans(option.labelKey) : app.translator.trans('fof-follow-tags.forum.sub_controls.follow_button');\n\n this.attrs.className = (this.attrs.className || '') + ' SubscriptionButton ' + 'SubscriptionButton--' + subscription;\n this.attrs.icon = buttonIcon;\n\n const preferences = app.session.user.preferences();\n const notifyEmail = preferences['notify_newPostInTag_email'];\n\n const tooltipText = extractText(\n app.translator.trans(\n notifyEmail ? 'fof-follow-tags.forum.sub_controls.notify_email_tooltip' : 'fof-follow-tags.forum.sub_controls.notify_alert_tooltip'\n )\n );\n\n return (\n \n {super.view(Object.assign({}, vnode, { children: buttonLabel }))}\n \n );\n }\n}\n","import FollowingHero from './FollowingHero';\nimport FollowingPageFilterDropdown from './FollowingPageFilterDropdown';\nimport NewDiscussionNotification from './NewDiscussionNotification';\nimport NewDiscussionTagNotification from './NewDiscussionTagNotification';\nimport NewPostNotification from './NewPostNotification';\nimport SubscriptionModal from './SubscriptionModal';\nimport SubscriptionOptionItem from './SubscriptionOptionItem';\nimport SubscriptionStateButton from './SubscriptionStateButton';\n\nexport const components = {\n FollowingPageFilterDropdown,\n NewDiscussionNotification,\n NewDiscussionTagNotification,\n NewPostNotification,\n SubscriptionModal,\n SubscriptionOptionItem,\n SubscriptionStateButton,\n FollowingHero,\n};\n","import app from 'flarum/forum/app';\nimport addSubscriptionControls from './addSubscriptionControls';\nimport addFollowedTagsDiscussions from './addFollowedTagsDiscussions';\nimport NewDiscussionNotification from './components/NewDiscussionNotification';\nimport NewPostNotification from './components/NewPostNotification';\nimport NewDiscussionTagNotification from './components/NewDiscussionTagNotification';\nimport addDiscussionBadge from './addDiscussionBadge';\nimport addPreferences from './addPreferences';\nimport extendNotificationGrid from './extendNotificationGrid';\nimport extendIndexPage from './extenders/extendIndexPage';\n\nexport * from './components';\nexport * from './utils';\n\napp.initializers.add(\n 'fof/follow-tags',\n () => {\n if (!app.initializers.has('flarum-tags')) {\n console.error('[fof/follow-tags] flarum/tags is not enabled');\n return;\n }\n\n addSubscriptionControls();\n extendIndexPage();\n\n if ('flarum-subscriptions' in flarum.extensions) {\n addDiscussionBadge();\n addFollowedTagsDiscussions();\n addPreferences();\n }\n\n app.notificationComponents.newPostInTag = NewPostNotification;\n app.notificationComponents.newDiscussionInTag = NewDiscussionNotification;\n app.notificationComponents.newDiscussionTag = NewDiscussionTagNotification;\n\n extendNotificationGrid();\n },\n -1\n);\n","import app from 'flarum/forum/app';\nimport { extend, override } from 'flarum/common/extend';\nimport IndexPage from 'flarum/forum/components/IndexPage';\nimport FollowingHero from '../components/FollowingHero';\nimport SubscriptionModal from '../components/SubscriptionModal';\nimport SubscriptionStateButton from '../components/SubscriptionStateButton';\n\nexport default function extendIndexPage() {\n extend(IndexPage.prototype, 'sidebarItems', function (items) {\n if (!this.currentTag() || !app.session.user) return;\n\n const tag = this.currentTag();\n if (!tag) return;\n\n if (items.has('newDiscussion')) items.setPriority('newDiscussion', 10);\n\n items.add(\n 'subscriptionButton',\n app.modal.show(SubscriptionModal, { model: tag })}\n />,\n 5\n );\n });\n\n override(IndexPage.prototype, 'hero', function (original: any) {\n if (app.current.get('routeName') === 'following') {\n return ;\n }\n\n return original();\n });\n}\n","import app from 'flarum/forum/app';\nimport { extend } from 'flarum/common/extend';\nimport Discussion from 'flarum/common/models/Discussion';\nimport Badge from 'flarum/common/components/Badge';\n\nimport isFollowingPage from './utils/isFollowingPage';\n\nexport default function addSubscriptionBadge() {\n extend(Discussion.prototype, 'badges', function (badges) {\n if (!isFollowingPage() || !this.tags()) {\n return;\n }\n\n const subscriptions = this.tags()\n .map((tag) => tag.subscription())\n .filter((state) => ['lurk', 'follow'].includes(state));\n\n const type = subscriptions.includes('lurk') ? 'lurking' : 'following';\n\n if (subscriptions.length) {\n badges.add(\n 'followTags',\n Badge.component({\n label: app.translator.trans(`fof-follow-tags.forum.badge.${type}_tag_tooltip`),\n icon: 'fas fa-user-tag',\n type: `${type}-tag`,\n })\n );\n }\n });\n}\n","import app from 'flarum/forum/app';\nimport { extend } from 'flarum/common/extend';\nimport NotificationGrid from 'flarum/forum/components/NotificationGrid';\n\nexport default function extendNotificationGrid() {\n extend(NotificationGrid.prototype, 'notificationTypes', function (items) {\n items.add('newDiscussionInTag', {\n name: 'newDiscussionInTag',\n icon: 'fas fa-user-tag',\n label: app.translator.trans('fof-follow-tags.forum.settings.notify_new_discussion_label'),\n });\n\n items.add('newPostInTag', {\n name: 'newPostInTag',\n icon: 'fas fa-user-tag',\n label: app.translator.trans('fof-follow-tags.forum.settings.notify_new_post_label'),\n });\n\n items.add('newDiscussionTag', {\n name: 'newDiscussionTag',\n icon: 'fas fa-user-tag',\n label: app.translator.trans('fof-follow-tags.forum.settings.notify_new_discussion_tag_label'),\n });\n });\n}\n"],"names":["__webpack_require__","module","getter","__esModule","d","a","exports","definition","key","o","Object","defineProperty","enumerable","get","obj","prop","prototype","hasOwnProperty","call","Symbol","toStringTag","value","flarum","core","compat","extensions","m","route","includes","app","opts","section","reduce","trans","options","getOptions","followingPageOptions","getDefaultFollowingFiltering","user","preference","preferences","followTagsPageDefault","_setPrototypeOf","p","setPrototypeOf","bind","__proto__","_inheritsLoose","subClass","superClass","create","constructor","FollowingPageFilterDropdown","view","selected","followTags","this","Dropdown","buttonClassName","label","keys","map","active","Button","icon","onclick","refresh","Component","NewDiscussionNotification","href","discussion","attrs","notification","subject","content","fromUser","title","excerpt","Notification","follow","lurk","ignore","hide","NewPostNotification","icons","postNumber","NewDiscussionTagNotification","FollowingHero","color","heroColor","className","classList","textContrastClass","style","undefined","viewItems","toArray","items","ItemList","add","contentItems","heroIcon","_extends","assign","target","i","arguments","length","source","apply","utils","isFollowingPage","subscriptionOptions","subscription","labelKey","descriptionKey","SubscriptionOptionItem","isSelected","SubscriptionModal","loading","canShowTooltip","oninit","vnode","Stream","model","notifyEmail","notifyAlert","tagName","name","formOptionItems","saveSubscription","subscriptionOptionItems","priority","forEach","option","index","disabled","isHidden","tag","url","attribute","id","method","body","data","requestData","then","res","pushPayload","redraw","Modal","SubscriptionStateButton","onbeforeupdate","find","opt","buttonIcon","buttonLabel","tooltipText","extractText","text","tooltipVisible","position","delay","children","components","has","models","tags","Model","extend","IndexPage","currentTag","setPriority","show","override","original","Discussion","badges","subscriptions","filter","state","type","Badge","DiscussionListState","params","SettingsPage","FieldSet","Select","onchange","savePreferences","newPostInTag","newDiscussionInTag","newDiscussionTag","NotificationGrid","console","error"],"sourceRoot":""} -------------------------------------------------------------------------------- /js/forum.js: -------------------------------------------------------------------------------- 1 | // exports from common are not imported on purpose as the utils namespace would be overridden 2 | // because of that common exports are imported then re-exported from forum 3 | export * from './src/forum'; 4 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fof/follow-tags", 3 | "version": "0.0.0", 4 | "private": true, 5 | "prettier": "@flarum/prettier-config", 6 | "dependencies": { 7 | "@flarum/prettier-config": "^1.0.0", 8 | "flarum-tsconfig": "^1.0.2", 9 | "flarum-webpack-config": "^2.0.0", 10 | "webpack": "^5.94.0", 11 | "webpack-cli": "^5.1.4", 12 | "webpack-merge": "^4.2.2" 13 | }, 14 | "devDependencies": { 15 | "prettier": "^3.0.2" 16 | }, 17 | "scripts": { 18 | "dev": "webpack --mode development --watch", 19 | "build": "webpack --mode production", 20 | "format": "prettier --write src", 21 | "format-check": "prettier --check src" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use Flarum's tsconfig as a starting point 3 | "extends": "flarum-tsconfig", 4 | // This will match all .ts, .tsx, .d.ts, .js, .jsx files in your `src` folder 5 | // and also tells your Typescript server to read core's global typings for 6 | // access to `dayjs` and `$` in the global namespace. 7 | "include": ["src/**/*", "../vendor/flarum/core/js/dist-typings/@types/**/*", "../vendor/flarum/tags/js/dist-typings/@types/**/*", "../vendor/flarum/subscriptions/js/dist-typings/@types/**/*"], 8 | "compilerOptions": { 9 | // This will output typings to `dist-typings` 10 | "declarationDir": "./dist-typings", 11 | "baseUrl": ".", 12 | "paths": { 13 | "flarum/*": ["../vendor/flarum/core/js/dist-typings/*"], 14 | "flarum/tags": ["../vendor/flarum/tags/js/dist-typings/*"], 15 | "flarum/subscriptions": ["../vendor/flarum/subscriptions/js/dist-typings/*"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const config = require('flarum-webpack-config'); 3 | 4 | module.exports = merge(config({ 5 | useExtensions: [], 6 | }), { 7 | externals: [{ 8 | 'flarum/subscriptions/*': 'subscriptions/*' 9 | }] 10 | }); 11 | -------------------------------------------------------------------------------- /migrations/2019_06_11_000000_add_subscription_to_users_tags_table.php: -------------------------------------------------------------------------------- 1 | ['enum', 'allowed' => ['follow', 'lurk', 'ignore'], 'nullable' => true], 16 | ]); 17 | -------------------------------------------------------------------------------- /migrations/2019_06_28_000000_add_hide_subscription_option.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 16 | $connection = $schema->getConnection(); 17 | $prefix = $connection->getTablePrefix(); 18 | 19 | $connection->statement("ALTER TABLE {$prefix}tag_user MODIFY COLUMN subscription ENUM('follow', 'lurk', 'ignore', 'hide')"); 20 | }, 21 | 'down' => function (Builder $schema) { 22 | $connection = $schema->getConnection(); 23 | $prefix = $connection->getTablePrefix(); 24 | 25 | $connection->statement("ALTER TABLE {$prefix}tag_user MODIFY COLUMN subscription ENUM('follow', 'lurk', 'ignore')"); 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /migrations/2021_01_22_000000_add_indicies.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 17 | $schema->table('tag_user', function (Blueprint $table) { 18 | $table->index(['user_id', 'subscription']); 19 | $table->index(['subscription']); 20 | }); 21 | }, 22 | 23 | 'down' => function (Builder $schema) { 24 | $schema->table('tag_user', function (Blueprint $table) { 25 | $table->dropIndex(['subscription']); 26 | $table->dropIndex(['user_id', 'subscription']); 27 | }); 28 | }, 29 | ]; 30 | -------------------------------------------------------------------------------- /migrations/2022_05_20_000000_add_timestamps_to_tag_user_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 17 | $schema->table('tag_user', function (Blueprint $table) { 18 | $table->timestamp('created_at')->nullable(); 19 | $table->timestamp('updated_at')->nullable(); 20 | }); 21 | 22 | // do this manually because dbal doesn't recognize timestamp columns 23 | $connection = $schema->getConnection(); 24 | $prefix = $connection->getTablePrefix(); 25 | $connection->statement("ALTER TABLE `{$prefix}tag_user` MODIFY created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP"); 26 | $connection->statement("ALTER TABLE `{$prefix}tag_user` MODIFY updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"); 27 | }, 28 | 29 | 'down' => function (Builder $schema) { 30 | $schema->table('tag_user', function (Blueprint $table) { 31 | $table->dropColumn('created_at'); 32 | $table->dropColumn('updated_at'); 33 | }); 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/flarum/phpstan/extension.neon 3 | 4 | parameters: 5 | # The level will be increased in Flarum 2.0 6 | level: 5 7 | paths: 8 | - extend.php 9 | - src 10 | excludePaths: 11 | - *.blade.php 12 | checkMissingIterableValueType: false 13 | databaseMigrationsPath: ['migrations'] 14 | -------------------------------------------------------------------------------- /resources/less/forum.less: -------------------------------------------------------------------------------- 1 | .SubscriptionButton { 2 | width: 100%; // as per your dropdown's button width 3 | display: inline-block; // to make it sit alongside other elements 4 | 5 | &--follow { 6 | .Button--color(#de8e00, #fff2ae); 7 | } 8 | 9 | &--lurk { 10 | color: #de8e00; 11 | background: #fff2ae; 12 | } 13 | 14 | // ... any other subscription state styles similar to the dropdown 15 | } 16 | 17 | // Existing media query for mobile 18 | @media @phone { 19 | // New media query styles for SubscriptionStateButton 20 | .IndexPage-nav .SubscriptionButton { 21 | background: transparent; 22 | width: auto; 23 | height: var(--header-height-phone, 46px); 24 | right: 50px; 25 | 26 | .Button-icon { 27 | font-size: 20px; 28 | margin-right: 0; 29 | } 30 | 31 | .Button-label { 32 | display: none; 33 | } 34 | } 35 | } 36 | 37 | // Fix css for 1.0 Tooltip update 38 | .ButtonGroup .Button + .tooltip + .Button { 39 | margin-left: 1px; 40 | } 41 | 42 | @primary-btn-bg: @primary-color; 43 | @primary-btn-text: @body-bg-light; 44 | @secondary-btn-bg: @control-bg; 45 | @secondary-btn-text: @text-color; 46 | 47 | // Subscription Modal 48 | .SubscriptionModal { 49 | .Modal-body { 50 | color: @text-color; 51 | } 52 | 53 | .Button.Button--primary { 54 | background-color: @primary-btn-bg; 55 | color: @primary-btn-text; 56 | } 57 | 58 | .Form-group { 59 | margin-bottom: 20px; 60 | } 61 | } 62 | 63 | // Subscription Options List 64 | .SubscriptionOptionsList { 65 | display: flex; 66 | flex-direction: column; 67 | gap: 10px; 68 | } 69 | 70 | .SubscriptionOption { 71 | padding: 10px; 72 | border: 1px solid @muted-color; 73 | border-radius: @border-radius; 74 | background-color: @body-bg; 75 | cursor: pointer; 76 | display: flex; 77 | align-items: center; 78 | gap: 10px; 79 | margin-bottom: 10px; // Added margin for spacing between each item 80 | 81 | &:last-child { 82 | margin-bottom: 0; // Ensure the last item doesn't have extra margin 83 | } 84 | 85 | &:hover { 86 | background-color: darken(@secondary-btn-bg, 10%); 87 | } 88 | 89 | &.selected { 90 | background-color: var(--following-bg); 91 | color: var(--following-color); 92 | 93 | .SubscriptionOption-icon { 94 | color: var(--following-color); 95 | } 96 | 97 | .SubscriptionOption-title { 98 | color: var(--following-color); 99 | } 100 | 101 | .SubscriptionOption-description { 102 | color: var(--following-color); 103 | } 104 | } 105 | 106 | .SubscriptionOption-icon { 107 | flex-shrink: 0; 108 | font-size: 1.2em; // Increase the size of the icon 109 | } 110 | 111 | .SubscriptionOption-title { 112 | font-weight: bold; // Making the title bold 113 | flex: 0 0 100px; // Assuming a width of 150px for the title, you can adjust this as per your design 114 | } 115 | 116 | .SubscriptionOption-description { 117 | flex-grow: 1; // This will make the description take up the remaining width 118 | padding-left: 10px; // Some spacing between the title and the description 119 | } 120 | 121 | .SubscriptionOption-selectedIcon { 122 | margin-left: auto; // This will push the check icon to the right side 123 | color: @alert-success-color; 124 | padding-left: 10px; // Some spacing between the description and the check icon 125 | } 126 | } 127 | 128 | @media @tablet-up { 129 | .Hero.FollowingHero { 130 | .container { 131 | padding-bottom: 40px; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /resources/locale/en.yml: -------------------------------------------------------------------------------- 1 | fof-follow-tags: 2 | forum: 3 | settings: 4 | notify_new_discussion_label: Someone creates a discussion in a tag I'm following 5 | notify_new_post_label: Someone posts in a tag I'm following 6 | notify_new_discussion_tag_label: Someone re-tags a discussion to a tag I'm following 7 | 8 | index: 9 | follow_tag_button: Follow 10 | unfollow_tag_button: Stop Following 11 | 12 | following: 13 | following_none_label: => fof-follow-tags.ref.filtering_options.none 14 | following_tags_label: => fof-follow-tags.ref.filtering_options.tags 15 | 16 | badge: 17 | following_tag_tooltip: Following 18 | lurking_tag_tooltip: Lurking 19 | 20 | sub_controls: 21 | header: "Subscription for {tagName}" 22 | subscription_label: "Select your subscription level for {tagName}" 23 | follow_button: => fof-follow-tags.ref.follow 24 | following_button: => fof-follow-tags.ref.following 25 | following_text: Be notified of all new discussions. 26 | hiding_button: => fof-follow-tags.ref.hiding 27 | hiding_text: Hide discussions from All Discussions page. 28 | ignoring_button: => fof-follow-tags.ref.ignoring 29 | ignoring_text: Never be notified. 30 | lurking_button: => fof-follow-tags.ref.lurking 31 | lurking_text: Be notified of all new discussions and replies. 32 | not_following_button: Not Following 33 | not_following_text: "Be notified only when @mentioned." 34 | notify_alert_tooltip: Get a forum notification when there are new discussions or posts 35 | notify_email_tooltip: Get an email when there are new discussions or posts 36 | submit_button: Confirm subscription 37 | 38 | hero: 39 | title: => flarum-subscriptions.forum.index.following_link 40 | 41 | notifications: 42 | new_discussion_text: "{username} started" 43 | new_post_text: "{username} posted" 44 | new_discussion_tag_text: "{username} changed the tag" 45 | 46 | 47 | user: 48 | settings: 49 | heading: Follow Tags 50 | 51 | filter_label: What to show by default in the Following page 52 | 53 | admin: 54 | settings: 55 | following_page_default_label: Default Following Page Filtering 56 | 57 | following_none_label: => fof-follow-tags.ref.filtering_options.none 58 | following_tags_label: => fof-follow-tags.ref.filtering_options.tags 59 | 60 | 61 | email: 62 | subject: 63 | newDiscussionInTag: "[Tag | New Discussion] {title}" 64 | newPostInTag: "[Tag | New Post] {title}" 65 | newDiscussionTag: "[Tag | Changed Tag] {title}" 66 | body: 67 | newDiscussionInTag: | 68 | Hey {recipient_display_name}, 69 | 70 | {actor_display_name} started a discussion in a tag you're following: {discussion_title} 71 | 72 | To view the new discussion, check out the following link: 73 | {discussion_url} 74 | 75 | --- 76 | 77 | {post_content} 78 | newPostInTag: | 79 | Hey {recipient_display_name}, 80 | 81 | {actor_display_name} posted in a discussion on a tag you're following: {discussion_title} 82 | 83 | To view the new activity, check out the following link: 84 | {post_url} 85 | 86 | --- 87 | 88 | {post_content} 89 | 90 | --- 91 | 92 | You won't receive any more notifications about this discussion until you're up to date. 93 | newDiscussionTag: | 94 | Hey {recipient_display_name}, 95 | 96 | {actor_display_name} just changed the tag on a discussion by {author_display_name} to a tag you're following {discussion_title} 97 | 98 | To view the new discussion, check out the following link: 99 | {discussion_url} 100 | ref: 101 | follow: Follow 102 | following: Following 103 | hiding: Hiding 104 | ignoring: Ignoring 105 | lurking: Lurking 106 | 107 | filtering_options: 108 | none: Followed Discussions 109 | tags: Followed Tags 110 | 111 | flarum-gdpr: 112 | lib: 113 | data: 114 | tagsubscription: 115 | export_description: Exports details of the subscription level for a tag, if one exists. 116 | anonymize_description: Removes the subscription of the user to the tag. 117 | -------------------------------------------------------------------------------- /resources/views/emails/newDiscussion.blade.php: -------------------------------------------------------------------------------- 1 | {!! $translator->trans('fof-follow-tags.email.body.newDiscussionInTag', [ 2 | 'recipient_display_name' => $user->display_name, 3 | 'actor_display_name' => $blueprint->discussion->user->display_name, 4 | 'discussion_title' => $blueprint->discussion->title, 5 | 'discussion_url' => $url->to('forum')->route('discussion', ['id' => $blueprint->discussion->id]), 6 | 'post_content' => $blueprint->post->content, 7 | ]) !!} 8 | -------------------------------------------------------------------------------- /resources/views/emails/newPost.blade.php: -------------------------------------------------------------------------------- 1 | {!! $translator->trans('fof-follow-tags.email.body.newPostInTag', [ 2 | 'recipient_display_name' => $user->display_name, 3 | 'actor_display_name' => $blueprint->post->user->display_name, 4 | 'discussion_title' => $blueprint->post->discussion->title, 5 | 'post_url' => $url->to('forum')->route('discussion', ['id' => $blueprint->post->discussion_id, 'near' => $blueprint->post->number]), 6 | 'post_content' => $blueprint->post->content, 7 | ]) !!} 8 | -------------------------------------------------------------------------------- /resources/views/emails/newTag.blade.php: -------------------------------------------------------------------------------- 1 | {!! $translator->trans('fof-follow-tags.email.body.newDiscussionTag', [ 2 | 'recipient_display_name' => $user->display_name, 3 | 'actor_display_name' => $blueprint->actor->display_name, 4 | 'author_display_name' => $blueprint->discussion->user->display_name, 5 | 'discussion_title' => $blueprint->discussion->title, 6 | 'discussion_url' => $url->to('forum')->route('discussion', ['id' => $blueprint->discussion->id]), 7 | ]) !!} 8 | -------------------------------------------------------------------------------- /src/AddTagSubscriptionAttribute.php: -------------------------------------------------------------------------------- 1 | getStateFor($tag, $serializer->getActor()); 24 | 25 | $attributes['subscription'] = $state->subscription ?? null; 26 | 27 | return $attributes; 28 | } 29 | 30 | /** 31 | * Get the *correct* state for the tag based on the actor. 32 | * If a state hasn't been already loaded OR the loaded state is not for the actor, load the correct state. 33 | * 34 | * @param Tag $tag 35 | * @param User $actor 36 | * 37 | * @return TagState 38 | */ 39 | public function getStateFor(Tag $tag, User $actor): TagState 40 | { 41 | // If $tag->state is loaded *and* null, this might be because the actor doesn't have tag-specific state 42 | // OR because the wrong actor has been used for loading it. `$tag->stateFor()` will return the correct state. 43 | // If it doesn't exist, it returns a dummy state with the correct actor & tag IDs. 44 | if (!$tag->relationLoaded('state') || is_null($tag->state) || $tag->state->user_id !== $actor->id) { 45 | $tag->setRelation('state', $tag->stateFor($actor)); 46 | } 47 | 48 | return $tag->state; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Controllers/ChangeTagSubscription.php: -------------------------------------------------------------------------------- 1 | events = $events; 37 | } 38 | 39 | protected function data(ServerRequestInterface $request, Document $document) 40 | { 41 | $actor = RequestUtil::getActor($request); 42 | $id = Arr::get($request->getQueryParams(), 'id'); 43 | $subscription = Arr::get($request->getParsedBody(), 'data.subscription'); 44 | 45 | $actor->assertRegistered(); 46 | 47 | $tag = Tag::whereVisibleTo($actor)->findOrFail($id); 48 | 49 | /** @var TagState $state */ 50 | $state = $tag->stateFor($actor); 51 | 52 | if (!in_array($subscription, ['follow', 'lurk', 'ignore', 'hide'])) { 53 | $subscription = null; 54 | } 55 | 56 | $state->subscription = $subscription; 57 | 58 | $this->events->dispatch(new Event\SubscriptionChanging($actor, $tag, $state, $request)); 59 | 60 | $state->save(); 61 | 62 | $this->events->dispatch(new Event\SubscriptionChanged($actor, $tag, $state)); 63 | 64 | return $tag; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Data/TagSubscription.php: -------------------------------------------------------------------------------- 1 | each(function (Tag $tag) use (&$exportData) { 27 | $state = $tag->stateFor($this->user); 28 | $sanitized = $this->sanitize($state); 29 | 30 | // if the sanitized data has more than simply the `tag_id` key, we'll export it 31 | if (count($sanitized) > 1) { 32 | $exportData[] = ["FollowTagSubscription/tag-{$tag->id}.json" => $this->encodeForExport($sanitized)]; 33 | } 34 | }); 35 | 36 | return $exportData; 37 | } 38 | 39 | public function sanitize(TagState $state): array 40 | { 41 | return Arr::except($state->toArray(), ['user_id', 'marked_as_read_at', 'is_hidden']); 42 | } 43 | 44 | public function anonymize(): void 45 | { 46 | Tag::query() 47 | ->each(function (Tag $tag) { 48 | $state = $tag->stateFor($this->user); 49 | 50 | if ($state->exists) { 51 | $state->subscription = null; 52 | $state->save(); 53 | } 54 | }); 55 | } 56 | 57 | public static function deleteDescription(): string 58 | { 59 | return self::anonymizeDescription(); 60 | } 61 | 62 | public function delete(): void 63 | { 64 | $this->anonymize(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Event/SubscriptionChanged.php: -------------------------------------------------------------------------------- 1 | actor = $actor; 38 | $this->tag = $tag; 39 | $this->state = $state; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Event/SubscriptionChanging.php: -------------------------------------------------------------------------------- 1 | actor = $actor; 44 | $this->tag = $tag; 45 | $this->state = $state; 46 | $this->request = $request; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Jobs/NotificationJob.php: -------------------------------------------------------------------------------- 1 | $recipients 35 | */ 36 | protected function sync(NotificationSyncer $syncer, BlueprintInterface $blueprint, Collection $recipients): void 37 | { 38 | $syncer->sync($blueprint, $recipients->all()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Jobs/SendNotificationWhenDiscussionIsReTagged.php: -------------------------------------------------------------------------------- 1 | actor = $actor; 36 | $this->discussion = $discussion; 37 | } 38 | 39 | public function handle(NotificationSyncer $notifications) 40 | { 41 | if (!$this->discussion->exists) { 42 | return; 43 | } 44 | 45 | /** 46 | * @var Collection|null $tags 47 | * 48 | * @phpstan-ignore-next-line 49 | */ 50 | $tags = $this->discussion->tags; 51 | $tagIds = $tags->map->id; 52 | $firstPost = $this->discussion->firstPost ?? $this->discussion->posts()->orderBy('number')->first(); 53 | 54 | if ($tags->isEmpty() || !$firstPost) { 55 | return; 56 | } 57 | 58 | // The `select(...)` part is not mandatory here, but makes the query safer. See #55. 59 | $notify = User::select('users.*') 60 | ->where('users.id', '!=', $this->actor->id) 61 | ->join('tag_user', 'tag_user.user_id', '=', 'users.id') 62 | ->whereIn('tag_user.tag_id', $tagIds->all()) 63 | ->whereIn('tag_user.subscription', ['follow', 'lurk']) 64 | ->get() 65 | ->unique() 66 | ->reject(function ($user) use ($firstPost, $tags) { 67 | return $tags->map->stateFor($user)->map->subscription->contains('ignore') 68 | || !$this->discussion->newQuery()->whereVisibleTo($user)->find($this->discussion->id) 69 | || !$firstPost->isVisibleTo($user); 70 | }); 71 | 72 | $this->sync( 73 | $notifications, 74 | new NewDiscussionTagBlueprint($this->actor, $this->discussion, $firstPost), 75 | $notify 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Jobs/SendNotificationWhenDiscussionIsStarted.php: -------------------------------------------------------------------------------- 1 | discussion = $discussion; 30 | } 31 | 32 | public function handle(NotificationSyncer $notifications) 33 | { 34 | if (!$this->discussion->exists) { 35 | return; 36 | } 37 | 38 | /** 39 | * @var Collection|null $tags 40 | * 41 | * @phpstan-ignore-next-line 42 | */ 43 | $tags = $this->discussion->tags; 44 | $tagIds = $tags->map->id; 45 | $firstPost = $this->discussion->firstPost ?? $this->discussion->posts()->orderBy('number')->first(); 46 | 47 | if ($tags->isEmpty() || !$firstPost) { 48 | return; 49 | } 50 | 51 | // The `select(...)` part is not mandatory here, but makes the query safer. See #55. 52 | $notify = User::select('users.*') 53 | ->where('users.id', '!=', $this->discussion->user_id) 54 | ->join('tag_user', 'tag_user.user_id', '=', 'users.id') 55 | ->whereIn('tag_user.tag_id', $tagIds->all()) 56 | ->whereIn('tag_user.subscription', ['follow', 'lurk']) 57 | ->get() 58 | ->unique() 59 | ->reject(function ($user) use ($firstPost, $tags) { 60 | return $tags->map->stateFor($user)->map->subscription->contains('ignore') 61 | || !$this->discussion->newQuery()->whereVisibleTo($user)->find($this->discussion->id) 62 | || !$firstPost->isVisibleTo($user); 63 | }); 64 | 65 | $this->sync($notifications, new NewDiscussionBlueprint($this->discussion, $firstPost), $notify); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Jobs/SendNotificationWhenReplyIsPosted.php: -------------------------------------------------------------------------------- 1 | post = $post; 35 | $this->lastPostNumber = $lastPostNumber; 36 | } 37 | 38 | public function handle(NotificationSyncer $notifications) 39 | { 40 | if (!$this->post->exists) { 41 | return; 42 | } 43 | 44 | $discussion = $this->post->discussion; 45 | 46 | /** 47 | * @var Collection|null $tags 48 | * 49 | * @phpstan-ignore-next-line 50 | */ 51 | $tags = $discussion->tags; 52 | $tagIds = $tags->map->id; 53 | 54 | if (!$tags || $tags->isEmpty()) { 55 | return; 56 | } 57 | 58 | $notify = $this->post->discussion->readers() 59 | // The `select(...)` part is not mandatory here, but makes the query safer. See #55. 60 | ->select('users.*') 61 | ->where('users.id', '!=', $this->post->user_id) 62 | ->join('tag_user', 'tag_user.user_id', '=', 'users.id') 63 | ->whereIn('tag_user.tag_id', $tagIds->all()) 64 | ->where('tag_user.subscription', 'lurk') 65 | ->where('discussion_user.last_read_post_number', '>=', $this->lastPostNumber - 1) 66 | ->get() 67 | ->unique() 68 | ->reject(function (User $user) use ($tags) { 69 | return $tags->map->stateFor($user)->map->subscription->contains('ignore') 70 | || !$this->post->isVisibleTo($user); 71 | }); 72 | 73 | $this->sync($notifications, new NewPostBlueprint($this->post), $notify); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Listeners/DeleteNotificationWhenDiscussionIsHiddenOrDeleted.php: -------------------------------------------------------------------------------- 1 | notifications = $notifications; 32 | } 33 | 34 | /** 35 | * @param Hidden|Deleted $event 36 | */ 37 | public function handle($event) 38 | { 39 | $this->notifications->delete(new NewDiscussionBlueprint($event->discussion)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Listeners/DeleteNotificationWhenPostIsHiddenOrDeleted.php: -------------------------------------------------------------------------------- 1 | notifications = $notifications; 32 | } 33 | 34 | /** 35 | * @param Hidden|Deleted $event 36 | */ 37 | public function handle($event) 38 | { 39 | $this->notifications->delete(new NewPostBlueprint($event->post)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Listeners/PreventMentionNotificationsFromIgnoredTags.php: -------------------------------------------------------------------------------- 1 | pluck('id'); 28 | /** @phpstan-ignore-next-line */ 29 | $tags = $blueprint->post->discussion->tags->pluck('id'); 30 | 31 | if ($tags->isEmpty()) { 32 | return $recipients; 33 | } 34 | 35 | $ids = TagState::whereIn('tag_id', $tags) 36 | ->whereIn('user_id', $ids) 37 | ->where('subscription', 'ignore') 38 | ->pluck('user_id'); 39 | 40 | if ($ids->isEmpty()) { 41 | return $recipients; 42 | } 43 | 44 | return array_filter($recipients, function ($user) use ($ids) { 45 | return !$ids->contains($user->id); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Listeners/QueueNotificationJobs.php: -------------------------------------------------------------------------------- 1 | listen(Started::class, [$this, 'whenDiscussionStarted']); 26 | $events->listen(Saving::class, [$this, 'whenPostCreated']); 27 | $events->listen(PostWasApproved::class, [$this, 'whenPostApproved']); 28 | $events->listen(DiscussionWasTagged::class, [$this, 'whenDiscussionTagChanged']); 29 | } 30 | 31 | public function whenDiscussionStarted(Started $event) 32 | { 33 | $event->discussion->afterSave(function ($discussion) { 34 | resolve('flarum.queue.connection')->push( 35 | new Jobs\SendNotificationWhenDiscussionIsStarted($discussion) 36 | ); 37 | }); 38 | } 39 | 40 | public function whenPostCreated(Saving $event) 41 | { 42 | if ($event->post->exists) { 43 | return; 44 | } 45 | 46 | $event->post->afterSave(function ($post) { 47 | if (!$post->discussion->exists || $post->number == 1) { 48 | return; 49 | } 50 | 51 | resolve('flarum.queue.connection')->push( 52 | new Jobs\SendNotificationWhenReplyIsPosted($post, $post->number - 1) 53 | ); 54 | }); 55 | } 56 | 57 | public function whenPostApproved(PostWasApproved $event) 58 | { 59 | $event->post->afterSave(function ($post) { 60 | if (!$post->discussion->exists) { 61 | return; 62 | } 63 | 64 | resolve('flarum.queue.connection')->push( 65 | $post->number == 1 66 | ? new Jobs\SendNotificationWhenDiscussionIsStarted($post->discussion) 67 | : new Jobs\SendNotificationWhenReplyIsPosted($post, $post->number - 1) 68 | ); 69 | }); 70 | } 71 | 72 | public function whenDiscussionTagChanged(DiscussionWasTagged $event) 73 | { 74 | resolve('flarum.queue.connection')->push( 75 | new Jobs\SendNotificationWhenDiscussionIsReTagged($event->actor, $event->discussion) 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Listeners/RestoreNotificationWhenDiscussionIsRestored.php: -------------------------------------------------------------------------------- 1 | notifications = $notifications; 31 | } 32 | 33 | public function handle(Restored $event) 34 | { 35 | $this->notifications->restore(new NewDiscussionBlueprint($event->discussion)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Listeners/RestoreNotificationWhenPostIsRestored.php: -------------------------------------------------------------------------------- 1 | notifications = $notifications; 31 | } 32 | 33 | public function handle(Restored $event) 34 | { 35 | $this->notifications->restore(new NewPostBlueprint($event->post)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Notifications/NewDiscussionBlueprint.php: -------------------------------------------------------------------------------- 1 | discussion = $discussion; 35 | $this->post = $post; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function getFromUser() 42 | { 43 | return $this->discussion->user; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function getSubject() 50 | { 51 | return $this->discussion; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function getData() 58 | { 59 | return []; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function getEmailView() 66 | { 67 | return ['text' => 'fof-follow-tags::emails.newDiscussion']; 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | public function getEmailSubject(TranslatorInterface $translator) 74 | { 75 | return $translator->trans('fof-follow-tags.email.subject.newDiscussionInTag', [ 76 | '{title' => $this->discussion->title, 77 | ]); 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public static function getType() 84 | { 85 | return 'newDiscussionInTag'; 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public static function getSubjectModel() 92 | { 93 | return Discussion::class; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Notifications/NewDiscussionTagBlueprint.php: -------------------------------------------------------------------------------- 1 | actor = $actor; 41 | $this->discussion = $discussion; 42 | $this->post = $post; 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function getFromUser() 49 | { 50 | return $this->actor; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function getSubject() 57 | { 58 | return $this->discussion; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function getData() 65 | { 66 | return []; 67 | } 68 | 69 | /** 70 | * {@inheritdoc} 71 | */ 72 | public function getEmailView() 73 | { 74 | return ['text' => 'fof-follow-tags::emails.newTag']; 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | public function getEmailSubject(TranslatorInterface $translator) 81 | { 82 | return $translator->trans('fof-follow-tags.email.subject.newDiscussionTag', [ 83 | 'actor' => $this->actor, 84 | 'title' => $this->discussion->title, 85 | ]); 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public static function getType() 92 | { 93 | return 'newDiscussionTag'; 94 | } 95 | 96 | /** 97 | * {@inheritdoc} 98 | */ 99 | public static function getSubjectModel() 100 | { 101 | return Discussion::class; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Notifications/NewPostBlueprint.php: -------------------------------------------------------------------------------- 1 | post = $post; 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function getSubject() 39 | { 40 | return $this->post->discussion; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function getFromUser() 47 | { 48 | return $this->post->user; 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function getData() 55 | { 56 | return ['postNumber' => (int) $this->post->number]; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function getEmailView() 63 | { 64 | return ['text' => 'fof-follow-tags::emails.newPost']; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function getEmailSubject(TranslatorInterface $translator) 71 | { 72 | return $translator->trans('fof-follow-tags.email.subject.newPostInTag', [ 73 | 'title' => $this->post->discussion->title, 74 | ]); 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | public static function getType() 81 | { 82 | return 'newPostInTag'; 83 | } 84 | 85 | /** 86 | * {@inheritdoc} 87 | */ 88 | public static function getSubjectModel() 89 | { 90 | return Discussion::class; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Search/FollowTagsFilter.php: -------------------------------------------------------------------------------- 1 | constrain($filterState->getQuery(), $filterState->getActor(), $negate); 30 | } 31 | 32 | protected function constrain(Builder $query, User $actor, bool $negate) 33 | { 34 | if ($actor->isGuest()) { 35 | return; 36 | } 37 | 38 | $method = $negate ? 'whereNotIn' : 'whereIn'; 39 | 40 | $query->$method('discussions.id', function ($query) use ($actor) { 41 | $query->select('discussion_id') 42 | ->from('discussion_tag') 43 | ->whereIn('tag_id', function ($query) use ($actor) { 44 | $query->select('tag_id') 45 | ->from((new TagState())->getTable()) 46 | ->where('user_id', $actor->id) 47 | ->whereIn('subscription', ['lurk', 'follow']); 48 | }); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Search/HideTagsFilter.php: -------------------------------------------------------------------------------- 1 | getActor(); 23 | 24 | if ($actor->isGuest() || array_key_exists('tag', $criteria->query)) { 25 | return; 26 | } 27 | 28 | $state->getQuery()->whereNotIn('discussions.id', function ($query) use ($actor) { 29 | $query->select('discussion_id') 30 | ->from('discussion_tag') 31 | ->whereIn('tag_id', function ($query) use ($actor) { 32 | $query->select('tag_id') 33 | ->from((new TagState())->getTable()) 34 | ->where('user_id', $actor->id) 35 | ->where('subscription', 'hide'); 36 | }); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/integration/ExtensionDepsTrait.php: -------------------------------------------------------------------------------- 1 | extension('flarum-tags'); 19 | $this->extension('flarum-mentions'); 20 | $this->extension('fof-follow-tags'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/integration/TagsDefinitionTrait.php: -------------------------------------------------------------------------------- 1 | 1, 'name' => 'General', 'slug' => 'general', 'position' => 0, 'parent_id' => null], 20 | ['id' => 2, 'name' => 'Testing', 'slug' => 'testing', 'position' => 1, 'parent_id' => null], 21 | ['id' => 3, 'name' => 'Playground', 'slug' => 'playground', 'position' => 1, 'parent_id' => null], 22 | ['id' => 4, 'name' => 'Archive', 'slug' => 'archive', 'position' => 2, 'parent_id' => null, 'is_restricted' => true], 23 | ['id' => 5, 'name' => 'General Child', 'slug' => 'general-child', 'position' => 0, 'parent_id' => 1], 24 | ['id' => 6, 'name' => 'Testing Child', 'slug' => 'testing-child', 'position' => 0, 'parent_id' => 2], 25 | 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/integration/notifications/NotificationsCountTest.php: -------------------------------------------------------------------------------- 1 | extensionDeps(); 33 | 34 | $this->prepareDatabase([ 35 | 'users' => [ 36 | $this->normalUser(), 37 | ], 38 | 'tags' => $this->tags(), 39 | 'tag_user' => [ 40 | ['user_id' => 2, 'tag_id' => 1, 'is_hidden' => 0, 'subscription' => 'follow', 'created_at' => Carbon::now()->toDateTimeString()], 41 | ['user_id' => 2, 'tag_id' => 5, 'is_hidden' => 0, 'subscription' => 'follow', 'created_at' => Carbon::now()->toDateTimeString()], 42 | ['user_id' => 2, 'tag_id' => 2, 'is_hidden' => 0, 'subscription' => 'lurk', 'created_at' => Carbon::now()->toDateTimeString()], 43 | ['user_id' => 2, 'tag_id' => 6, 'is_hidden' => 0, 'subscription' => 'lurk', 'created_at' => Carbon::now()->toDateTimeString()], 44 | ], 45 | 'discussion_tag' => [ 46 | ['discussion_id' => 1, 'tag_id' => 2, 'created_at' => Carbon::now()->toDateTimeString()], 47 | ['discussion_id' => 1, 'tag_id' => 6, 'created_at' => Carbon::now()->toDateTimeString()], 48 | ], 49 | 'discussion_user' => [ 50 | ['user_id' => 2, 'discussion_id' => 1, 'last_read_post_number' => 1, 'last_read_at' => Carbon::now()->toDateTimeString()], 51 | ], 52 | 'discussions' => [ 53 | ['id' => 1, 'title' => 'The quick brown fox jumps over the lazy dog', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'participant_count' => 1], 54 | ], 55 | 'posts' => [ 56 | ['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

Following

', 'is_private' => 0, 'number' => 1], 57 | ], 58 | ]); 59 | } 60 | 61 | /** 62 | * @test 63 | */ 64 | public function single_notification_sent_when_following_tag_and_subtag() 65 | { 66 | $response = $this->send( 67 | $this->request('POST', '/api/discussions', [ 68 | 'authenticatedAs' => 1, 69 | 'json' => [ 70 | 'data' => [ 71 | 'attributes' => [ 72 | 'title' => 'New discussion', 73 | 'content' => '

New Post

', 74 | ], 75 | 'relationships' => [ 76 | 'tags' => [ 77 | 'data' => [ 78 | ['type' => 'tags', 'id' => 1], 79 | ['type' => 'tags', 'id' => 5], 80 | ], 81 | ], 82 | ], 83 | ], 84 | ], 85 | ]) 86 | ); 87 | 88 | $this->assertEquals(201, $response->getStatusCode()); 89 | 90 | $notificationRecipient = 2; 91 | 92 | $response = $this->send( 93 | $this->request('GET', '/api/notifications', [ 94 | 'authenticatedAs' => $notificationRecipient, 95 | ]) 96 | ); 97 | 98 | $this->assertEquals(200, $response->getStatusCode()); 99 | 100 | $response = json_decode($response->getBody(), true); 101 | 102 | $this->assertEquals(1, count($response['data'])); 103 | $this->assertEquals('newDiscussionInTag', $response['data'][0]['attributes']['contentType']); 104 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 105 | $this->assertEquals(1, Notification::query()->count()); 106 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 107 | $this->assertEquals(2, Notification::query()->first()->user_id); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | public function single_notification_sent_when_lurking_tag_and_subtag() 114 | { 115 | $response = $this->send( 116 | $this->request('POST', '/api/posts', [ 117 | 'authenticatedAs' => 1, 118 | 'json' => [ 119 | 'data' => [ 120 | 'attributes' => [ 121 | 'content' => '

New Post

', 122 | ], 123 | 'relationships' => [ 124 | 'discussion' => [ 125 | 'data' => [ 126 | 'type' => 'discussions', 127 | 'id' => 1, 128 | ], 129 | ], 130 | ], 131 | ], 132 | ], 133 | ]) 134 | ); 135 | 136 | $this->assertEquals(201, $response->getStatusCode()); 137 | 138 | $notificationRecipient = 2; 139 | 140 | $response = $this->send( 141 | $this->request('GET', '/api/notifications', [ 142 | 'authenticatedAs' => $notificationRecipient, 143 | ]) 144 | ); 145 | 146 | $this->assertEquals(200, $response->getStatusCode()); 147 | 148 | $response = json_decode($response->getBody(), true); 149 | 150 | $this->assertEquals(1, count($response['data'])); 151 | $this->assertEquals('newPostInTag', $response['data'][0]['attributes']['contentType']); 152 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 153 | $this->assertEquals(1, Notification::query()->count()); 154 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 155 | $this->assertEquals(2, Notification::query()->first()->user_id); 156 | } 157 | 158 | /** 159 | * @test 160 | */ 161 | public function single_notification_sent_when_following_tag_and_subtag_and_discussion_retagged() 162 | { 163 | $response = $this->send( 164 | $this->request('PATCH', '/api/discussions/1', [ 165 | 'authenticatedAs' => 1, 166 | 'json' => [ 167 | 'data' => [ 168 | 'attributes' => [], 169 | 'relationships' => [ 170 | 'tags' => [ 171 | 'data' => [ 172 | ['type' => 'tags', 'id' => 1], 173 | ['type' => 'tags', 'id' => 5], 174 | ], 175 | ], 176 | ], 177 | ], 178 | ], 179 | ]) 180 | ); 181 | 182 | $this->assertEquals(200, $response->getStatusCode()); 183 | 184 | $notificationRecipient = 2; 185 | 186 | $response = $this->send( 187 | $this->request('GET', '/api/notifications', [ 188 | 'authenticatedAs' => $notificationRecipient, 189 | ]) 190 | ); 191 | 192 | $this->assertEquals(200, $response->getStatusCode()); 193 | 194 | $response = json_decode($response->getBody(), true); 195 | 196 | $this->assertEquals(1, count($response['data'])); 197 | $this->assertEquals('newDiscussionTag', $response['data'][0]['attributes']['contentType']); 198 | 199 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 200 | $this->assertEquals(1, Notification::query()->count()); 201 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 202 | $this->assertEquals(2, Notification::query()->first()->user_id); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /tests/integration/notifications/NotificationsTest.php: -------------------------------------------------------------------------------- 1 | extensionDeps(); 33 | 34 | $this->prepareDatabase([ 35 | 'users' => [ 36 | $this->normalUser(), 37 | ], 38 | 'tags' => $this->tags(), 39 | 'tag_user' => [ 40 | ['user_id' => 2, 'tag_id' => 1, 'is_hidden' => 0, 'subscription' => 'follow', 'created_at' => Carbon::now()->toDateTimeString()], 41 | ['user_id' => 2, 'tag_id' => 2, 'is_hidden' => 0, 'subscription' => 'lurk', 'created_at' => Carbon::now()->toDateTimeString()], 42 | ['user_id' => 2, 'tag_id' => 3, 'is_hidden' => 0, 'subscription' => 'ignore', 'created_at' => Carbon::now()->toDateTimeString()], 43 | ], 44 | 'discussion_tag' => [ 45 | ['discussion_id' => 1, 'tag_id' => 1, 'created_at' => Carbon::now()->toDateTimeString()], 46 | ['discussion_id' => 2, 'tag_id' => 2, 'created_at' => Carbon::now()->toDateTimeString()], 47 | ['discussion_id' => 3, 'tag_id' => 3, 'created_at' => Carbon::now()->toDateTimeString()], 48 | ], 49 | 'discussion_user' => [ 50 | ['user_id' => 2, 'discussion_id' => 1, 'last_read_post_number' => 1, 'last_read_at' => Carbon::now()->toDateTimeString()], 51 | ['user_id' => 2, 'discussion_id' => 2, 'last_read_post_number' => 1, 'last_read_at' => Carbon::now()->toDateTimeString()], 52 | ['user_id' => 2, 'discussion_id' => 3, 'last_read_post_number' => 1, 'last_read_at' => Carbon::now()->toDateTimeString()], 53 | ], 54 | 'discussions' => [ 55 | ['id' => 1, 'title' => 'The quick brown fox jumps over the lazy dog', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'participant_count' => 1], 56 | ['id' => 2, 'title' => 'The quick brown fox jumps over the lazy dog', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'participant_count' => 1], 57 | ['id' => 3, 'title' => 'The quick brown fox jumps over the lazy dog', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'participant_count' => 1], 58 | ], 59 | 'posts' => [ 60 | ['id' => 1, 'discussion_id' => 1, 'user_id' => 2, 'type' => 'comment', 'content' => '

Following

', 'is_private' => 0, 'number' => 1], 61 | ['id' => 2, 'discussion_id' => 2, 'user_id' => 2, 'type' => 'comment', 'content' => '

Lurking

', 'is_private' => 0, 'number' => 1], 62 | ['id' => 3, 'discussion_id' => 3, 'user_id' => 2, 'type' => 'comment', 'content' => '

Ignoring

', 'is_private' => 0, 'number' => 1], 63 | ], 64 | ]); 65 | } 66 | 67 | /** 68 | * @test 69 | */ 70 | public function notification_sent_when_new_discussion_in_followed_tag() 71 | { 72 | $response = $this->send( 73 | $this->request('POST', '/api/discussions', [ 74 | 'authenticatedAs' => 1, 75 | 'json' => [ 76 | 'data' => [ 77 | 'attributes' => [ 78 | 'title' => 'New discussion', 79 | 'content' => '

New Post

', 80 | ], 81 | 'relationships' => [ 82 | 'tags' => [ 83 | 'data' => [ 84 | ['type' => 'tags', 'id' => 1], 85 | ], 86 | ], 87 | ], 88 | ], 89 | ], 90 | ]) 91 | ); 92 | 93 | $this->assertEquals(201, $response->getStatusCode()); 94 | 95 | $notificationRecipient = 2; 96 | 97 | $response = $this->send( 98 | $this->request('GET', '/api/notifications', [ 99 | 'authenticatedAs' => $notificationRecipient, 100 | ]) 101 | ); 102 | 103 | $this->assertEquals(200, $response->getStatusCode()); 104 | 105 | $response = json_decode($response->getBody(), true); 106 | 107 | $this->assertEquals(1, count($response['data'])); 108 | $this->assertEquals('newDiscussionInTag', $response['data'][0]['attributes']['contentType']); 109 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 110 | $this->assertEquals(1, Notification::query()->count()); 111 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 112 | $this->assertEquals(2, Notification::query()->first()->user_id); 113 | } 114 | 115 | /** 116 | * @test 117 | */ 118 | public function no_notification_sent_when_new_post_in_followed_tag() 119 | { 120 | $response = $this->send( 121 | $this->request('POST', '/api/posts', [ 122 | 'authenticatedAs' => 1, 123 | 'json' => [ 124 | 'data' => [ 125 | 'attributes' => [ 126 | 'content' => '

New Post

', 127 | ], 128 | 'relationships' => [ 129 | 'discussion' => [ 130 | 'data' => [ 131 | 'type' => 'discussions', 132 | 'id' => 1, 133 | ], 134 | ], 135 | ], 136 | ], 137 | ], 138 | ]) 139 | ); 140 | 141 | $this->assertEquals(201, $response->getStatusCode()); 142 | 143 | $notificationRecipient = 2; 144 | 145 | $response = $this->send( 146 | $this->request('GET', '/api/notifications', [ 147 | 'authenticatedAs' => $notificationRecipient, 148 | ]) 149 | ); 150 | 151 | $this->assertEquals(200, $response->getStatusCode()); 152 | 153 | $response = json_decode($response->getBody(), true); 154 | 155 | $this->assertEquals(0, count($response['data'])); 156 | $this->assertEquals(0, User::query()->find($notificationRecipient)->notifications()->count()); 157 | $this->assertEquals(0, Notification::query()->count()); 158 | } 159 | 160 | /** 161 | * @test 162 | */ 163 | public function notification_sent_when_new_discussion_in_lurked_tag() 164 | { 165 | $response = $this->send( 166 | $this->request('POST', '/api/discussions', [ 167 | 'authenticatedAs' => 1, 168 | 'json' => [ 169 | 'data' => [ 170 | 'attributes' => [ 171 | 'title' => 'New discussion', 172 | 'content' => '

New Post

', 173 | ], 174 | 'relationships' => [ 175 | 'tags' => [ 176 | 'data' => [ 177 | ['type' => 'tags', 'id' => 2], 178 | ], 179 | ], 180 | ], 181 | ], 182 | ], 183 | ]) 184 | ); 185 | 186 | $this->assertEquals(201, $response->getStatusCode()); 187 | 188 | $notificationRecipient = 2; 189 | 190 | $response = $this->send( 191 | $this->request('GET', '/api/notifications', [ 192 | 'authenticatedAs' => $notificationRecipient, 193 | ]) 194 | ); 195 | 196 | $this->assertEquals(200, $response->getStatusCode()); 197 | 198 | $response = json_decode($response->getBody(), true); 199 | 200 | $this->assertEquals(1, count($response['data'])); 201 | $this->assertEquals('newDiscussionInTag', $response['data'][0]['attributes']['contentType']); 202 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 203 | $this->assertEquals(1, Notification::query()->count()); 204 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 205 | $this->assertEquals(2, Notification::query()->first()->user_id); 206 | } 207 | 208 | /** 209 | * @test 210 | */ 211 | public function notification_sent_when_new_post_in_lurked_tag() 212 | { 213 | /** 214 | * Please notice that a notification is only sent when the user 215 | * has read the last post in the discussion which is in a lurking tag. 216 | * 217 | * See `last_read_post_number` in the `discussion_user` table. 218 | * 219 | * Users won't receive notifications for posts in discussions they haven't read, 220 | * even if the discussion is in a tag, they lurk. 221 | */ 222 | $response = $this->send( 223 | $this->request('POST', '/api/posts', [ 224 | 'authenticatedAs' => 1, 225 | 'json' => [ 226 | 'data' => [ 227 | 'attributes' => [ 228 | 'content' => '

New Post

', 229 | ], 230 | 'relationships' => [ 231 | 'discussion' => [ 232 | 'data' => [ 233 | 'type' => 'discussions', 234 | 'id' => 2, 235 | ], 236 | ], 237 | ], 238 | ], 239 | ], 240 | ]) 241 | ); 242 | 243 | $this->assertEquals(201, $response->getStatusCode()); 244 | 245 | $notificationRecipient = 2; 246 | 247 | $response = $this->send( 248 | $this->request('GET', '/api/notifications', [ 249 | 'authenticatedAs' => $notificationRecipient, 250 | ]) 251 | ); 252 | 253 | $this->assertEquals(200, $response->getStatusCode()); 254 | 255 | $response = json_decode($response->getBody(), true); 256 | 257 | $this->assertEquals(1, count($response['data'])); 258 | $this->assertEquals('newPostInTag', $response['data'][0]['attributes']['contentType']); 259 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 260 | $this->assertEquals(1, Notification::query()->count()); 261 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 262 | $this->assertEquals(2, Notification::query()->first()->user_id); 263 | } 264 | 265 | /** 266 | * @test 267 | */ 268 | public function no_notification_sent_when_new_post_mention_in_ignored_tag() 269 | { 270 | $response = $this->send( 271 | $this->request('POST', '/api/posts', [ 272 | 'authenticatedAs' => 1, 273 | 'json' => [ 274 | 'data' => [ 275 | 'attributes' => [ 276 | 'content' => '@"NORMAL$"#p3', 277 | ], 278 | 'relationships' => [ 279 | 'discussion' => [ 280 | 'data' => [ 281 | 'type' => 'discussions', 282 | 'id' => 3, 283 | ], 284 | ], 285 | ], 286 | ], 287 | ], 288 | ]) 289 | ); 290 | 291 | $this->assertEquals(201, $response->getStatusCode()); 292 | 293 | $notificationRecipient = 2; 294 | 295 | $response = $this->send( 296 | $this->request('GET', '/api/notifications', [ 297 | 'authenticatedAs' => $notificationRecipient, 298 | ]) 299 | ); 300 | 301 | $this->assertEquals(200, $response->getStatusCode()); 302 | 303 | $response = json_decode($response->getBody(), true); 304 | 305 | $this->assertEquals(0, count($response['data'])); 306 | $this->assertEquals(0, User::query()->find($notificationRecipient)->notifications()->count()); 307 | $this->assertEquals(0, Notification::query()->count()); 308 | } 309 | 310 | /** 311 | * @test 312 | */ 313 | public function no_notification_sent_when_new_user_mention_in_ignored_tag() 314 | { 315 | $response = $this->send( 316 | $this->request('POST', '/api/posts', [ 317 | 'authenticatedAs' => 1, 318 | 'json' => [ 319 | 'data' => [ 320 | 'attributes' => [ 321 | 'content' => '@normal', 322 | ], 323 | 'relationships' => [ 324 | 'discussion' => [ 325 | 'data' => [ 326 | 'type' => 'discussions', 327 | 'id' => 3, 328 | ], 329 | ], 330 | ], 331 | ], 332 | ], 333 | ]) 334 | ); 335 | 336 | $this->assertEquals(201, $response->getStatusCode()); 337 | 338 | $notificationRecipient = 2; 339 | 340 | $response = $this->send( 341 | $this->request('GET', '/api/notifications', [ 342 | 'authenticatedAs' => $notificationRecipient, 343 | ]) 344 | ); 345 | 346 | $this->assertEquals(200, $response->getStatusCode()); 347 | 348 | $response = json_decode($response->getBody(), true); 349 | 350 | $this->assertEquals(0, count($response['data'])); 351 | $this->assertEquals(0, User::query()->find($notificationRecipient)->notifications()->count()); 352 | $this->assertEquals(0, Notification::query()->count()); 353 | } 354 | 355 | /** 356 | * @test 357 | */ 358 | public function notification_sent_when_discussion_retagged_to_accessible_tag() 359 | { 360 | $response = $this->send( 361 | $this->request('PATCH', '/api/discussions/1', [ 362 | 'authenticatedAs' => 1, 363 | 'json' => [ 364 | 'data' => [ 365 | 'attributes' => [], 366 | 'relationships' => [ 367 | 'tags' => [ 368 | 'data' => [ 369 | ['type' => 'tags', 'id' => 2], 370 | ], 371 | ], 372 | ], 373 | ], 374 | ], 375 | ]) 376 | ); 377 | 378 | $this->assertEquals(200, $response->getStatusCode()); 379 | 380 | $notificationRecipient = 2; 381 | 382 | $response = $this->send( 383 | $this->request('GET', '/api/notifications', [ 384 | 'authenticatedAs' => $notificationRecipient, 385 | ]) 386 | ); 387 | 388 | $this->assertEquals(200, $response->getStatusCode()); 389 | 390 | $response = json_decode($response->getBody(), true); 391 | 392 | $this->assertEquals(1, count($response['data'])); 393 | $this->assertEquals('newDiscussionTag', $response['data'][0]['attributes']['contentType']); 394 | 395 | $this->assertEquals(1, User::query()->find($notificationRecipient)->notifications()->count()); 396 | $this->assertEquals(1, Notification::query()->count()); 397 | $this->assertEquals(1, Notification::query()->first()->from_user_id); 398 | $this->assertEquals(2, Notification::query()->first()->user_id); 399 | } 400 | 401 | /** 402 | * @test 403 | */ 404 | public function no_notification_sent_when_discussion_retagged_to_restricted_tag() 405 | { 406 | $response = $this->send( 407 | $this->request('PATCH', '/api/discussions/1', [ 408 | 'authenticatedAs' => 1, 409 | 'json' => [ 410 | 'data' => [ 411 | 'attributes' => [], 412 | 'relationships' => [ 413 | 'tags' => [ 414 | 'data' => [ 415 | ['type' => 'tags', 'id' => 4], 416 | ], 417 | ], 418 | ], 419 | ], 420 | ], 421 | ]) 422 | ); 423 | 424 | $this->assertEquals(200, $response->getStatusCode()); 425 | 426 | $notificationRecipient = 2; 427 | 428 | $response = $this->send( 429 | $this->request('GET', '/api/notifications', [ 430 | 'authenticatedAs' => $notificationRecipient, 431 | ]) 432 | ); 433 | 434 | $this->assertEquals(200, $response->getStatusCode()); 435 | 436 | $response = json_decode($response->getBody(), true); 437 | 438 | $this->assertEquals(0, count($response['data'])); 439 | $this->assertEquals(0, User::query()->find($notificationRecipient)->notifications()->count()); 440 | $this->assertEquals(0, Notification::query()->count()); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /tests/integration/setup.php: -------------------------------------------------------------------------------- 1 | run(); 19 | -------------------------------------------------------------------------------- /tests/phpunit.integration.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ../src/ 17 | 18 | 19 | 20 | 21 | ./integration 22 | ./integration/tmp 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/phpunit.unit.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ../src/ 17 | 18 | 19 | 20 | 21 | ./unit 22 | 23 | 24 | 25 | 26 | 27 | 28 | --------------------------------------------------------------------------------