diff --git a/.env.example b/.env.example index 485130b484c..f6e4206c2d2 100644 --- a/.env.example +++ b/.env.example @@ -88,6 +88,7 @@ SLACK_ENDPOINT=https://myconan.net/null/ # INITIAL_HELP_FORUM_IDS="5 47 85" # ISSUE_FORUM_IDS= # FORUM_POST_MINIMUM_PLAYS=200 +# PROJECT_LOVED_FORUM_ID=120 # GA_TRACKING_ID=UA-xxx diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index 66f76c62a79..f6f17397a75 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -498,6 +498,7 @@ public function me($mode = null) * - statistics.variants * - support_level * - user_achievements + * - voted_in_project_loved * * @urlParam user integer required Id or username of the user. Id lookup is prioritised unless `key` parameter is specified. Previous usernames are also checked in some cases. Example: 1 * @urlParam mode string [GameMode](#gamemode). User default mode will be used if not specified. Example: osu diff --git a/app/Libraries/ProjectLovedPollsCache.php b/app/Libraries/ProjectLovedPollsCache.php new file mode 100644 index 00000000000..b90e03cb2e1 --- /dev/null +++ b/app/Libraries/ProjectLovedPollsCache.php @@ -0,0 +1,49 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries; + +use App\Models\Forum\Topic; +use App\Models\User; +use App\Traits\LocallyCached; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection; + +class ProjectLovedPollsCache +{ + use LocallyCached; + + /** + * Get all topics on the Project Loved forum that have an open poll and finite poll length. + */ + public function all(): Collection + { + $topics = $this->cachedMemoize(__FUNCTION__, function () { + return Topic + ::where('forum_id', config('osu.forum.project_loved_forum_id')) + ->whereRaw('poll_start + poll_length > ?', [Carbon::now()->getTimestamp()]) + ->get(); + }); + + if ($topics->contains(fn (Topic $topic) => !$topic->poll()->isOpen())) { + $this->resetCache(); + return $this->all(); + } + + return $topics; + } + + /** + * Check if the user voted in any open Project Loved polls. + */ + public function userVotedAny(User $user): bool + { + return $this->memoize(__FUNCTION__.':'.$user->getKey(), function () use ($user) { + return $this->all()->contains(fn (Topic $topic) => $topic->poll()->votedBy($user)); + }); + } +} diff --git a/app/Listeners/OctaneResetLocalCache.php b/app/Listeners/OctaneResetLocalCache.php index f7f3a3eac03..92918e8463e 100644 --- a/app/Listeners/OctaneResetLocalCache.php +++ b/app/Listeners/OctaneResetLocalCache.php @@ -11,5 +11,6 @@ public function handle($event): void { app('chat-filters')->incrementResetTicker(); app('groups')->incrementResetTicker(); + app('loved-polls')->incrementResetTicker(); } } diff --git a/app/Models/Forum/TopicPoll.php b/app/Models/Forum/TopicPoll.php index e5c6adac595..6d9bb755da8 100644 --- a/app/Models/Forum/TopicPoll.php +++ b/app/Models/Forum/TopicPoll.php @@ -106,7 +106,7 @@ public function save() return false; } - return DB::transaction(function () { + DB::transaction(function () { $this->topic->update([ 'poll_title' => $this->params['title'], 'poll_start' => Carbon::now(), @@ -133,9 +133,13 @@ public function save() 'poll_option_text' => $value, ]); } - - return true; }); + + if ($this->topic->forum_id === config('osu.forum.project_loved_forum_id')) { + app('loved-polls')->resetCache(); + } + + return true; } public function setTopic($topic) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 61cef4dbc74..9c8b76774d2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -14,6 +14,7 @@ use App\Libraries\OsuAuthorize; use App\Libraries\OsuCookieJar; use App\Libraries\OsuMessageSelector; +use App\Libraries\ProjectLovedPollsCache; use App\Libraries\RouteSection; use App\Libraries\User\ScorePins; use Datadog; @@ -34,6 +35,7 @@ class AppServiceProvider extends ServiceProvider 'assets-manifest' => AssetsManifest::class, 'chat-filters' => ChatFilters::class, 'groups' => Groups::class, + 'loved-polls' => ProjectLovedPollsCache::class, 'route-section' => RouteSection::class, 'score-pins' => ScorePins::class, ]; @@ -55,6 +57,7 @@ public function boot() app('OsuAuthorize')->resetCache(); app('groups')->incrementResetTicker(); app('chat-filters')->incrementResetTicker(); + app('loved-polls')->incrementResetTicker(); Datadog::increment( config('datadog-helper.prefix_web').'.queue.run', diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index 6e632d1e35d..72326201504 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -39,6 +39,7 @@ class UserCompactTransformer extends TransformerAbstract 'mapping_follower_count', 'previous_usernames', 'support_level', + 'voted_in_project_loved', ]; protected string $mode; @@ -85,6 +86,7 @@ class UserCompactTransformer extends TransformerAbstract 'unread_pm_count', 'user_achievements', 'user_preferences', + 'voted_in_project_loved', // TODO: should be changed to rank_history // TODO: should be alphabetically ordered but lazer relies on being after statistics. can revert to alphabetical after 2020-05-01 'rankHistory', @@ -427,6 +429,11 @@ public function includeUserPreferences(User $user) ])); } + public function includeVotedInProjectLoved(User $user) + { + return $this->primitive(app('loved-polls')->userVotedAny($user)); + } + public function setMode(string $mode) { $this->mode = $mode; diff --git a/config/osu.php b/config/osu.php index 4cc86fd1593..641c32962af 100644 --- a/config/osu.php +++ b/config/osu.php @@ -107,6 +107,7 @@ 'minimum_plays' => get_int(env('FORUM_POST_MINIMUM_PLAYS')) ?? 200, 'necropost_months' => 6, 'poll_edit_hours' => get_int(env('FORUM_POLL_EDIT_HOURS')) ?? 1, + 'project_loved_forum_id' => get_int(env('PROJECT_LOVED_FORUM_ID')) ?? 120, 'double_post_time' => [ 'author' => 24, diff --git a/public/images/layout/loved-voted-sticker.png b/public/images/layout/loved-voted-sticker.png new file mode 100644 index 00000000000..f25c4036e67 Binary files /dev/null and b/public/images/layout/loved-voted-sticker.png differ diff --git a/resources/assets/less/bem-index.less b/resources/assets/less/bem-index.less index cd274f38f71..643fac75961 100644 --- a/resources/assets/less/bem-index.less +++ b/resources/assets/less/bem-index.less @@ -296,7 +296,8 @@ @import "bem/profile-previous-usernames"; @import "bem/profile-rank-count"; @import "bem/profile-stats"; -@import "bem/profile-tournament-banner"; +@import "bem/profile-header-banner"; +@import "bem/profile-header-banners"; @import "bem/proportional-container"; @import "bem/qtip"; @import "bem/quick-info"; diff --git a/resources/assets/less/bem/profile-header-banner.less b/resources/assets/less/bem/profile-header-banner.less new file mode 100644 index 00000000000..1ecd9b7018f --- /dev/null +++ b/resources/assets/less/bem/profile-header-banner.less @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.profile-header-banner { + @_top: profile-header-banner; + overflow: hidden; + + @media @desktop { + .own-layer(); + transition: transform .2s cubic-bezier(.08,.82,.17,1); + + &:hover { + transform: scale(1.1); + z-index: 1; + } + + & + & { + margin-left: 10px; + } + } + + &--loved-voted-sticker { + .default-border-radius(); + .link-plain(); + .link-white(); + background-color: @osu-colour-b6; + display: flex; + flex-direction: row-reverse; + align-items: center; + padding: 4px 0 4px 10px; + + .@{_top}__image { + margin-left: -10px; + } + } + + &--tournament { + @media @desktop { + .default-border-radius(); + width: 60%; + } + + .@{_top}__image { + width: 100%; + } + } +} diff --git a/resources/assets/less/bem/profile-header-banners.less b/resources/assets/less/bem/profile-header-banners.less new file mode 100644 index 00000000000..edc3da5a55b --- /dev/null +++ b/resources/assets/less/bem/profile-header-banners.less @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +.profile-header-banners { + display: flex; + align-items: end; + + @media @desktop { + margin-bottom: 10px; + } +} diff --git a/resources/assets/less/bem/profile-tournament-banner.less b/resources/assets/less/bem/profile-tournament-banner.less deleted file mode 100644 index a141a4ebd92..00000000000 --- a/resources/assets/less/bem/profile-tournament-banner.less +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -.profile-tournament-banner { - .own-layer(); - transition: transform .2s cubic-bezier(.08,.82,.17,1); - - @media @desktop { - position: absolute; - bottom: 100%; - margin-bottom: 10px; - left: 0; - width: 60%; - - &:hover { - transform: scale(1.1); - } - } - - &__image { - width: 100%; - - @media @desktop { - .default-border-radius(); - } - } -} diff --git a/resources/assets/less/variables.less b/resources/assets/less/variables.less index ecc98c23c5a..e53100141d2 100644 --- a/resources/assets/less/variables.less +++ b/resources/assets/less/variables.less @@ -93,7 +93,6 @@ @z-index--beatmaps-panel: 4; @z-index--profile-previous-usernames: 10; -@z-index--profile-tournament-banner: 11; @z-index--page-extra-tabs: 99; @z-index--profile-page-cover-selector: 100; // must be at least 1 more than @z-index--page-extra-tabs @z-index--beatmap-discussion-editor-insertion-menu: 100; diff --git a/resources/assets/lib/components/profile-header-banners.tsx b/resources/assets/lib/components/profile-header-banners.tsx new file mode 100644 index 00000000000..d5622ff7800 --- /dev/null +++ b/resources/assets/lib/components/profile-header-banners.tsx @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import Img2x from 'components/img2x'; +import UserProfileJson from 'interfaces/user-profile-json'; +import { route } from 'laroute'; +import * as React from 'react'; + +interface Props { + user: UserProfileJson; +} + +export default function ProfileHeaderBanners({ user }: Props) { + const { + active_tournament_banner: tournamentBanner, + voted_in_project_loved: votedInProjectLoved, + } = user; + + return ( +
+ {tournamentBanner != null && ( + + + + )} + {votedInProjectLoved && ( + + + {osu.trans('users.show.loved_voted_sticker.content')} + + )} +
+ ); +} diff --git a/resources/assets/lib/components/profile-tournament-banner.tsx b/resources/assets/lib/components/profile-tournament-banner.tsx deleted file mode 100644 index b903a7942a1..00000000000 --- a/resources/assets/lib/components/profile-tournament-banner.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import Img2x from 'components/img2x'; -import ProfileBannerJson from 'interfaces/profile-banner'; -import { route } from 'laroute'; -import * as React from 'react'; - -interface Props { - banner?: ProfileBannerJson | null; -} - -export default function ProfileTournamentBanner({ banner }: Props) { - if (banner == null) return null; - - return ( - - - - ); -} diff --git a/resources/assets/lib/interfaces/user-json.ts b/resources/assets/lib/interfaces/user-json.ts index 55ccc42ae6c..7a0c4d11e2c 100644 --- a/resources/assets/lib/interfaces/user-json.ts +++ b/resources/assets/lib/interfaces/user-json.ts @@ -62,6 +62,7 @@ interface UserJsonAvailableIncludes { unread_pm_count: number; user_achievements: UserAchievementJson[]; user_preferences: UserPreferencesJson; + voted_in_project_loved: boolean; } interface UserJsonDefaultAttributes { diff --git a/resources/assets/lib/interfaces/user-profile-json.ts b/resources/assets/lib/interfaces/user-profile-json.ts new file mode 100644 index 00000000000..fd7e3dc5a61 --- /dev/null +++ b/resources/assets/lib/interfaces/user-profile-json.ts @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import UserExtendedJson from './user-extended-json'; +import UserJson from './user-json'; + +type UserProfileHeaderIncludes = + | 'active_tournament_banner' + | 'badges' + | 'comments_count' + | 'follower_count' + | 'groups' + | 'mapping_follower_count' + | 'previous_usernames' + | 'support_level' + | 'voted_in_project_loved'; + +type UserProfileJson = UserExtendedJson & Required>; + +export default UserProfileJson; diff --git a/resources/assets/lib/modding-profile/header.coffee b/resources/assets/lib/modding-profile/header.coffee index 10b7b58db26..f9ef3ff54f0 100644 --- a/resources/assets/lib/modding-profile/header.coffee +++ b/resources/assets/lib/modding-profile/header.coffee @@ -3,7 +3,7 @@ import HeaderV4 from 'components/header-v4' import Img2x from 'components/img2x' -import ProfileTournamentBanner from 'components/profile-tournament-banner' +import ProfileHeaderBanners from 'components/profile-header-banners' import Badges from 'profile-page/badges' import Detail from 'profile-page/detail' import HeaderInfo from 'profile-page/header-info' @@ -22,8 +22,7 @@ export class Header extends React.Component 'data-page-id': 'main' el HeaderV4, backgroundImage: @props.user.cover.url - contentPrepend: el ProfileTournamentBanner, - banner: @props.user.active_tournament_banner + contentPrepend: el ProfileHeaderBanners, user: @props.user links: headerLinks(@props.user, 'modding') theme: 'users' div className: 'osu-page osu-page--users', diff --git a/resources/assets/lib/profile-page/extra-page-props.ts b/resources/assets/lib/profile-page/extra-page-props.ts index dc416f24f7f..2ca781c866e 100644 --- a/resources/assets/lib/profile-page/extra-page-props.ts +++ b/resources/assets/lib/profile-page/extra-page-props.ts @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import UserExtendedJson from 'interfaces/user-extended-json'; +import UserJson from 'interfaces/user-json'; +import UserProfileJson from 'interfaces/user-profile-json'; import Controller from './controller'; export const beatmapsetSections = [ @@ -22,20 +23,13 @@ export type HistoricalSection = typeof historicalSections[number]; type ProfilePageIncludes = 'account_history' - | 'active_tournament_banner' - | 'badges' | 'beatmap_playcounts_count' - | 'comments_count' | 'favourite_beatmapset_count' - | 'follower_count' | 'graveyard_beatmapset_count' - | 'groups' | 'loved_beatmapset_count' - | 'mapping_follower_count' | 'monthly_playcounts' | 'page' | 'pending_beatmapset_count' - | 'previous_usernames' | 'rank_history' | 'ranked_beatmapset_count' | 'replays_watched_counts' @@ -44,10 +38,9 @@ type ProfilePageIncludes = | 'scores_pinned_count' | 'scores_recent_count' | 'statistics' - | 'support_level' | 'user_achievements'; -export type ProfilePageUserJson = UserExtendedJson & Required>; +export type ProfilePageUserJson = UserProfileJson & Required>; export const profilePageSections = [...beatmapsetSections, ...topScoreSections, ...historicalSections, 'recentActivity', 'recentlyReceivedKudosu'] as const; export type ProfilePageSection = typeof profilePageSections[number]; diff --git a/resources/assets/lib/profile-page/header.tsx b/resources/assets/lib/profile-page/header.tsx index 3c0bc276cd4..c49223a26e3 100644 --- a/resources/assets/lib/profile-page/header.tsx +++ b/resources/assets/lib/profile-page/header.tsx @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import HeaderV4 from 'components/header-v4'; -import ProfileTournamentBanner from 'components/profile-tournament-banner'; +import ProfileHeaderBanners from 'components/profile-header-banners'; import { action, observable, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import { isModalShowing } from 'modal-helper'; @@ -53,7 +53,7 @@ export default class Header extends React.Component {
} + contentPrepend={} isCoverUpdating={this.props.controller.isUpdatingCover} links={headerLinks(this.props.controller.state.user, 'show')} theme='users' diff --git a/resources/assets/lib/user-multiplayer-index/header.tsx b/resources/assets/lib/user-multiplayer-index/header.tsx index f95ad552cb7..a4d1f9cd77d 100644 --- a/resources/assets/lib/user-multiplayer-index/header.tsx +++ b/resources/assets/lib/user-multiplayer-index/header.tsx @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. import HeaderV4 from 'components/header-v4'; -import ProfileTournamentBanner from 'components/profile-tournament-banner'; -import UserExtendedJson from 'interfaces/user-extended-json'; +import ProfileHeaderBanners from 'components/profile-header-banners'; import { MultiplayerTypeGroup } from 'interfaces/user-multiplayer-history-json'; +import UserProfileJson from 'interfaces/user-profile-json'; import Badges from 'profile-page/badges'; import Detail from 'profile-page/detail'; import HeaderInfo from 'profile-page/header-info'; @@ -14,7 +14,7 @@ import * as React from 'react'; interface Props { typeGroup: MultiplayerTypeGroup; - user: UserExtendedJson; + user: UserProfileJson; } export default class Header extends React.Component { @@ -23,7 +23,7 @@ export default class Header extends React.Component {
} + contentPrepend={} links={headerLinks(this.props.user, this.props.typeGroup)} theme='users' /> diff --git a/resources/assets/lib/user-multiplayer-index/main.tsx b/resources/assets/lib/user-multiplayer-index/main.tsx index 4bd69fe46b8..d61a66b0e7e 100644 --- a/resources/assets/lib/user-multiplayer-index/main.tsx +++ b/resources/assets/lib/user-multiplayer-index/main.tsx @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import UserProfileContainer from 'components/user-profile-container'; -import UserExtendedJson from 'interfaces/user-extended-json'; +import UserProfileJson from 'interfaces/user-profile-json'; import * as React from 'react'; import Header from 'user-multiplayer-index/header'; import MultiplayerHistory from 'user-multiplayer-index/multiplayer-history'; @@ -10,7 +10,7 @@ import MultiplayerHistoryStore from './multiplayer-history-store'; interface Props { store: MultiplayerHistoryStore; - user: UserExtendedJson; + user: UserProfileJson; } export default function Main(props: Props) { diff --git a/resources/lang/en/users.php b/resources/lang/en/users.php index a5f8b6e0825..a0e02884f67 100644 --- a/resources/lang/en/users.php +++ b/resources/lang/en/users.php @@ -376,6 +376,10 @@ 'twitter' => 'Twitter', 'website' => 'Website', ], + 'loved_voted_sticker' => [ + 'content' => 'I voted!', + 'title' => ':username voted in the latest round of Project Loved!', + ], 'not_found' => [ 'reason_1' => 'They may have changed their username.', 'reason_2' => 'The account may be temporarily unavailable due to security or abuse issues.', diff --git a/resources/views/docs/_structures/user_compact.md b/resources/views/docs/_structures/user_compact.md index 5cdc7367866..067a26a4626 100644 --- a/resources/views/docs/_structures/user_compact.md +++ b/resources/views/docs/_structures/user_compact.md @@ -70,6 +70,7 @@ support_level | | unread_pm_count | | user_achievements | | user_preferences | | +voted_in_project_loved | boolean