Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Overlays/Dashboard/CurrentlyOnline/RealtimeUserList.cs
5134 views
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Metadata;
using osu.Game.Overlays.Dashboard.Friends;
using osu.Game.Rulesets;
using osu.Game.Users;
using osuTK;

namespace osu.Game.Overlays.Dashboard.CurrentlyOnline
{
    internal partial class RealtimeUserList : CompositeDrawable
    {
        public readonly IBindable<UserSortCriteria> SortCriteria = new Bindable<UserSortCriteria>();
        public readonly IBindable<string> SearchText = new Bindable<string>();

        private readonly IBindableDictionary<int, UserPresence> onlineUserPresences = new BindableDictionary<int, UserPresence>();
        private readonly Dictionary<int, OnlineUserPanel> userPanels = new Dictionary<int, OnlineUserPanel>();
        private readonly OverlayPanelDisplayStyle style;

        private OnlineUserSearchContainer searchContainer = null!;

        [Resolved]
        private MetadataClient metadataClient { get; set; } = null!;

        [Cached(typeof(UserLookupCache))] // not used at the moment.
        private UserWithRankLookupCache userCache { get; set; } = new UserWithRankLookupCache();

        public RealtimeUserList(OverlayPanelDisplayStyle style)
        {
            this.style = style;

            RelativeSizeAxes = Axes.X;
            AutoSizeAxes = Axes.Y;
        }

        [BackgroundDependencyLoader]
        private void load()
        {
            AddInternal(userCache);

            InternalChild = searchContainer = new OnlineUserSearchContainer
            {
                RelativeSizeAxes = Axes.X,
                AutoSizeAxes = Axes.Y,
                Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 3),
                SortCriteria = { BindTarget = SortCriteria },
            };
        }

        protected override void LoadComplete()
        {
            base.LoadComplete();

            onlineUserPresences.BindTo(metadataClient.UserPresences);
            onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true);

            SearchText.BindValueChanged(onSearchTextChanged, true);

            Scheduler.AddDelayed(updateUsers, 2000, true);
        }

        private void onSearchTextChanged(ValueChangedEvent<string> search)
        {
            searchContainer.SearchTerm = search.NewValue;
        }

        private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e)
        {
            switch (e.Action)
            {
                case NotifyDictionaryChangedAction.Replace:
                    foreach ((int userId, var presence) in e.NewItems!)
                    {
                        if (userPanels.TryGetValue(userId, out var userPanel))
                            updateUserSpectateState(presence, userPanel);
                    }

                    break;

                case NotifyDictionaryChangedAction.Add:
                    pendingUsers.AddRange(e.NewItems!.Select(i => i.Key));

                    break;

                case NotifyDictionaryChangedAction.Remove:
                    foreach ((int userId, _) in e.OldItems!)
                    {
                        if (userPanels.Remove(userId, out var userPanel))
                            userPanel.Expire();

                        pendingUsers.Remove(userId);
                    }

                    break;
            }
        }

        private readonly HashSet<int> pendingUsers = new HashSet<int>();

        protected override void Update()
        {
            base.Update();

            // ReSharper disable once InconsistentlySynchronizedField
            if (pendingUsers.Count > 100)
                updateUsers();
        }

        private void updateUsers()
        {
            if (pendingUsers.Count == 0)
                return;

            // partitioning here is just to break up the requests.
            // without this, the intitial request will take seconds to minutes.
            const int partition_size = 50;

            for (int i = 0; i <= pendingUsers.Count / partition_size; i++)
            {
                int[] partitionedUsers = pendingUsers.Skip(i * partition_size).Take(partition_size).ToArray();

                userCache.GetUsersAsync(partitionedUsers).ContinueWith(task => Schedule(() =>
                {
                    var users = task.GetResultSafely();

                    foreach (APIUser? user in users)
                    {
                        if (user == null)
                            continue;

                        var presence = metadataClient.GetPresence(user.Id);

                        if (presence == null)
                            continue;

                        if (userPanels.TryGetValue(user.Id, out _))
                            return;

                        // This is quite dodgy – it affects the global `UserLookupCache`.
                        //
                        // but it's the best we can do for now.
                        // this should probaly be returned by server-spectator not osu-web.
                        user.LastVisit = DateTimeOffset.Now;

                        var panel = createUserPanel(user);
                        updateUserSpectateState(presence.Value, panel);
                        searchContainer.Add(userPanels[user.Id] = panel);
                    }
                }));
            }

            pendingUsers.Clear();
        }

        private static void updateUserSpectateState(UserPresence presence, OnlineUserPanel userPanel)
        {
            switch (presence.Activity)
            {
                default:
                    userPanel.CanSpectate.Value = false;
                    break;

                case UserActivity.InSoloGame:
                case UserActivity.InMultiplayerGame:
                case UserActivity.InPlaylistGame:
                    userPanel.CanSpectate.Value = true;
                    break;
            }
        }

        private OnlineUserPanel createUserPanel(APIUser user)
        {
            switch (style)
            {
                default:
                case OverlayPanelDisplayStyle.Card:
                    return new OnlineUserGridPanel(user)
                    {
                        Anchor = Anchor.TopCentre,
                        Origin = Anchor.TopCentre
                    };

                case OverlayPanelDisplayStyle.List:
                    return new OnlineUserListPanel(user);
            }
        }

        private partial class OnlineUserSearchContainer : SearchContainer<OnlineUserPanel>
        {
            public readonly IBindable<UserSortCriteria> SortCriteria = new Bindable<UserSortCriteria>();

            protected override void LoadComplete()
            {
                base.LoadComplete();
                SortCriteria.BindValueChanged(_ => InvalidateLayout(), true);
            }

            public override IEnumerable<Drawable> FlowingChildren
            {
                get
                {
                    IEnumerable<OnlineUserPanel> panels = base.FlowingChildren.OfType<OnlineUserPanel>();

                    switch (SortCriteria.Value)
                    {
                        default:
                        case UserSortCriteria.LastVisit:
                            // Todo: Last visit time is not currently updated according to realtime user presence.
                            return panels.OrderByDescending(panel => panel.User.LastVisit).ThenBy(panel => panel.User.Id);

                        case UserSortCriteria.Rank:
                            // Todo: Rank is not currently displayed in the panels. Additionally the sort mode kind of breaks if you change ruleset with this overlay open.
                            return panels.OrderByDescending(panel => panel.User.Rank?.Rank != null).ThenBy(panel => panel.User.Rank?.Rank ?? 0);

                        case UserSortCriteria.Username:
                            return panels.OrderBy(panel => panel.User.Username);
                    }
                }
            }
        }

        /// <summary>
        /// This is implemented local to avoid invalidating the full cache on ruleset change at a global `UserLookupCache` level.
        /// We should probably do better than this (server-spectator sending the rank data instead? something else?).
        /// </summary>
        private partial class UserWithRankLookupCache : UserLookupCache
        {
            [Resolved]
            private IBindable<RulesetInfo> ruleset { get; set; } = null!;

            protected override void LoadComplete()
            {
                base.LoadComplete();

                ruleset.BindValueChanged(ruleset =>
                {
                    if (ruleset.OldValue?.OnlineID != ruleset.NewValue?.OnlineID)
                        Clear();
                });
            }

            protected override LookupUsersRequest CreateRequest(IEnumerable<int> ids) => new LookupUsersRequest(ids.ToArray(), ruleset.Value?.OnlineID >= 0 ? ruleset.Value.OnlineID : null);
        }
    }
}