Merge branch 'master' into chat-mention

This commit is contained in:
Dean Herbert
2021-05-26 15:59:29 +09:00
2977 changed files with 152927 additions and 38374 deletions

View File

@ -22,7 +22,7 @@ namespace osu.Game.Online.Chat
public readonly ObservableCollection<User> Users = new ObservableCollection<User>();
[JsonProperty(@"users")]
private long[] userIds
private int[] userIds
{
set
{
@ -61,7 +61,7 @@ namespace osu.Game.Online.Chat
/// </summary>
public event Action<Message> MessageRemoved;
public bool ReadOnly => false; //todo not yet used.
public bool ReadOnly => false; // todo: not yet used.
public override string ToString() => Name;
@ -84,7 +84,8 @@ namespace osu.Game.Online.Chat
public long? LastReadId;
/// <summary>
/// Signalles if the current user joined this channel or not. Defaults to false.
/// Signals if the current user joined this channel or not. Defaults to false.
/// Note that this does not guarantee a join has completed. Check Id > 0 for confirmation.
/// </summary>
public Bindable<bool> Joined = new Bindable<bool>();

View File

@ -18,7 +18,7 @@ namespace osu.Game.Online.Chat
/// <summary>
/// Manages everything channel related
/// </summary>
public class ChannelManager : PollingComponent
public class ChannelManager : PollingComponent, IChannelPostTarget
{
/// <summary>
/// The channels the player joins on startup
@ -48,7 +48,8 @@ namespace osu.Game.Online.Chat
/// </summary>
public IBindableList<Channel> AvailableChannels => availableChannels;
private IAPIProvider api;
[Resolved]
private IAPIProvider api { get; set; }
public readonly BindableBool HighPollRate = new BindableBool();
@ -56,7 +57,7 @@ namespace osu.Game.Online.Chat
{
CurrentChannel.ValueChanged += currentChannelChanged;
HighPollRate.BindValueChanged(enabled => TimeBetweenPolls = enabled.NewValue ? 1000 : 6000, true);
HighPollRate.BindValueChanged(enabled => TimeBetweenPolls.Value = enabled.NewValue ? 1000 : 6000, true);
}
/// <summary>
@ -85,7 +86,7 @@ namespace osu.Game.Online.Chat
return;
CurrentChannel.Value = JoinedChannels.FirstOrDefault(c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Users.Any(u => u.Id == user.Id))
?? new Channel(user);
?? JoinChannel(new Channel(user));
}
private void currentChannelChanged(ValueChangedEvent<Channel> e)
@ -107,8 +108,7 @@ namespace osu.Game.Online.Chat
/// <param name="target">An optional target channel. If null, <see cref="CurrentChannel"/> will be used.</param>
public void PostMessage(string text, bool isAction = false, Channel target = null)
{
if (target == null)
target = CurrentChannel.Value;
target ??= CurrentChannel.Value;
if (target == null)
return;
@ -139,7 +139,7 @@ namespace osu.Game.Online.Chat
target.AddLocalEcho(message);
// if this is a PM and the first message, we need to do a special request to create the PM channel
if (target.Type == ChannelType.PM && !target.Joined.Value)
if (target.Type == ChannelType.PM && target.Id == 0)
{
var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(target.Users.First(), message);
@ -152,7 +152,7 @@ namespace osu.Game.Online.Chat
createNewPrivateMessageRequest.Failure += exception =>
{
Logger.Error(exception, "Posting message failed.");
handlePostException(exception);
target.ReplaceMessage(message, null);
dequeueAndRun();
};
@ -171,7 +171,7 @@ namespace osu.Game.Online.Chat
req.Failure += exception =>
{
Logger.Error(exception, "Posting message failed.");
handlePostException(exception);
target.ReplaceMessage(message, null);
dequeueAndRun();
};
@ -184,6 +184,14 @@ namespace osu.Game.Online.Chat
dequeueAndRun();
}
private static void handlePostException(Exception exception)
{
if (exception is APIException apiException)
Logger.Log(apiException.Message, level: LogLevel.Important);
else
Logger.Error(exception, "Posting message failed.");
}
/// <summary>
/// Posts a command locally. Commands like /help will result in a help message written in the current channel.
/// </summary>
@ -191,18 +199,21 @@ namespace osu.Game.Online.Chat
/// <param name="target">An optional target channel. If null, <see cref="CurrentChannel"/> will be used.</param>
public void PostCommand(string text, Channel target = null)
{
if (target == null)
target = CurrentChannel.Value;
target ??= CurrentChannel.Value;
if (target == null)
return;
var parameters = text.Split(new[] { ' ' }, 2);
var parameters = text.Split(' ', 2);
string command = parameters[0];
string content = parameters.Length == 2 ? parameters[1] : string.Empty;
switch (command)
{
case "np":
AddInternal(new NowPlayingCommand());
break;
case "me":
if (string.IsNullOrWhiteSpace(content))
{
@ -229,11 +240,10 @@ namespace osu.Game.Online.Chat
}
JoinChannel(channel);
CurrentChannel.Value = channel;
break;
case "help":
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel]"));
target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /np"));
break;
default:
@ -264,7 +274,7 @@ namespace osu.Game.Online.Chat
// join any channels classified as "defaults"
if (joinDefaults && defaultChannels.Any(c => c.Equals(channel.Name, StringComparison.OrdinalIgnoreCase)))
JoinChannel(ch);
joinChannel(ch);
}
};
req.Failure += error =>
@ -285,7 +295,7 @@ namespace osu.Game.Online.Chat
/// <param name="channel">The channel </param>
private void fetchInitalMessages(Channel channel)
{
if (channel.Id <= 0) return;
if (channel.Id <= 0 || channel.MessagesLoaded) return;
var fetchInitialMsgReq = new GetMessagesRequest(channel);
fetchInitialMsgReq.Success += messages =>
@ -337,12 +347,13 @@ namespace osu.Game.Online.Chat
}
/// <summary>
/// Joins a channel if it has not already been joined.
/// Joins a channel if it has not already been joined. Must be called from the update thread.
/// </summary>
/// <param name="channel">The channel to join.</param>
/// <param name="alreadyJoined">Whether the channel has already been joined server-side. Will skip a join request.</param>
/// <returns>The joined channel. Note that this may not match the parameter channel as it is a backed object.</returns>
public Channel JoinChannel(Channel channel, bool alreadyJoined = false)
public Channel JoinChannel(Channel channel) => joinChannel(channel, true);
private Channel joinChannel(Channel channel, bool fetchInitialMessages = false)
{
if (channel == null) return null;
@ -351,35 +362,56 @@ namespace osu.Game.Online.Chat
// ensure we are joined to the channel
if (!channel.Joined.Value)
{
if (alreadyJoined)
channel.Joined.Value = true;
else
channel.Joined.Value = true;
switch (channel.Type)
{
switch (channel.Type)
{
case ChannelType.Public:
var req = new JoinChannelRequest(channel, api.LocalUser.Value);
req.Success += () => JoinChannel(channel, true);
req.Failure += ex => LeaveChannel(channel);
api.Queue(req);
return channel;
}
case ChannelType.Multiplayer:
// join is implicit. happens when you join a multiplayer game.
// this will probably change in the future.
joinChannel(channel, fetchInitialMessages);
return channel;
case ChannelType.PM:
var createRequest = new CreateChannelRequest(channel);
createRequest.Success += resChannel =>
{
if (resChannel.ChannelID.HasValue)
{
channel.Id = resChannel.ChannelID.Value;
handleChannelMessages(resChannel.RecentMessages);
channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none.
}
};
api.Queue(createRequest);
break;
default:
var req = new JoinChannelRequest(channel);
req.Success += () => joinChannel(channel, fetchInitialMessages);
req.Failure += ex => LeaveChannel(channel);
api.Queue(req);
return channel;
}
}
if (CurrentChannel.Value == null)
CurrentChannel.Value = channel;
if (!channel.MessagesLoaded)
else
{
// let's fetch a small number of messages to bring us up-to-date with the backlog.
fetchInitalMessages(channel);
if (fetchInitialMessages)
fetchInitalMessages(channel);
}
CurrentChannel.Value ??= channel;
return channel;
}
public void LeaveChannel(Channel channel)
/// <summary>
/// Leave the specified channel. Can be called from any thread.
/// </summary>
/// <param name="channel">The channel to leave.</param>
public void LeaveChannel(Channel channel) => Schedule(() =>
{
if (channel == null) return;
@ -390,10 +422,10 @@ namespace osu.Game.Online.Chat
if (channel.Joined.Value)
{
api.Queue(new LeaveChannelRequest(channel, api.LocalUser.Value));
api.Queue(new LeaveChannelRequest(channel));
channel.Joined.Value = false;
}
}
});
private long lastMessageId;
@ -415,7 +447,8 @@ namespace osu.Game.Online.Chat
foreach (var channel in updates.Presence)
{
// we received this from the server so should mark the channel already joined.
JoinChannel(channel, true);
channel.Joined.Value = true;
joinChannel(channel);
}
//todo: handle left channels
@ -466,12 +499,6 @@ namespace osu.Game.Online.Chat
api.Queue(req);
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api)
{
this.api = api;
}
}
/// <summary>

View File

@ -12,5 +12,6 @@ namespace osu.Game.Online.Chat
Temporary,
PM,
Group,
System,
}
}

View File

@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Online.Chat
@ -20,7 +21,10 @@ namespace osu.Game.Online.Chat
/// <summary>
/// Each word part of a chat link (split for word-wrap support).
/// </summary>
public List<Drawable> Parts;
public readonly List<Drawable> Parts;
[Resolved(CanBeNull = true)]
private OverlayColourProvider overlayColourProvider { get; set; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceivePositionalInputAt(screenSpacePos));
@ -34,7 +38,7 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
IdleColour = colours.Blue;
IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
}
protected override IEnumerable<Drawable> EffectTargets => Parts;

View File

@ -13,15 +13,17 @@ namespace osu.Game.Online.Chat
{
public class ExternalLinkOpener : Component
{
private GameHost host;
private DialogOverlay dialogOverlay;
[Resolved]
private GameHost host { get; set; }
[Resolved(CanBeNull = true)]
private DialogOverlay dialogOverlay { get; set; }
private Bindable<bool> externalLinkWarning;
[BackgroundDependencyLoader(true)]
private void load(GameHost host, DialogOverlay dialogOverlay, OsuConfigManager config)
private void load(OsuConfigManager config)
{
this.host = host;
this.dialogOverlay = dialogOverlay;
externalLinkWarning = config.GetBindable<bool>(OsuSetting.ExternalLinkWarning);
}

View File

@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
namespace osu.Game.Online.Chat
{
[Cached(typeof(IChannelPostTarget))]
public interface IChannelPostTarget
{
/// <summary>
/// Posts a message to the currently opened channel.
/// </summary>
/// <param name="text">The message text that is going to be posted</param>
/// <param name="isAction">Is true if the message is an action, e.g.: user is currently eating </param>
/// <param name="target">An optional target channel. If null, <see cref="ChannelManager.CurrentChannel"/> will be used.</param>
void PostMessage(string text, bool isAction = false, Channel target = null);
}
}

View File

@ -8,10 +8,8 @@ namespace osu.Game.Online.Chat
{
public class InfoMessage : LocalMessage
{
private static int infoID = -1;
public InfoMessage(string message)
: base(infoID--)
: base(null)
{
Timestamp = DateTimeOffset.Now;
Content = message;

View File

@ -59,7 +59,7 @@ namespace osu.Game.Online.Chat
return Id.Value.CompareTo(other.Id.Value);
}
public virtual bool Equals(Message other) => Id == other?.Id;
public virtual bool Equals(Message other) => Id.HasValue && Id == other?.Id;
// ReSharper disable once ImpureMethodCallOnReadonlyValueField
public override int GetHashCode() => Id.GetHashCode();

View File

@ -49,6 +49,18 @@ namespace osu.Game.Online.Chat
// Unicode emojis
private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])");
/// <summary>
/// The root URL for the website, used for chat link matching.
/// </summary>
public static string WebsiteRootUrl
{
set => websiteRootUrl = value
.Trim('/') // trim potential trailing slash/
.Split('/').Last(); // only keep domain name, ignoring protocol.
}
private static string websiteRootUrl = "osu.ppy.sh";
private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = null, char[] escapeChars = null)
{
int captureOffset = 0;
@ -78,13 +90,13 @@ namespace osu.Game.Online.Chat
{
result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText);
//since we just changed the line display text, offset any already processed links.
// since we just changed the line display text, offset any already processed links.
result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0);
var details = GetLinkDetails(linkText);
result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument));
//adjust the offset for processing the current matches group.
// adjust the offset for processing the current matches group.
captureOffset += m.Length - displayText.Length;
}
}
@ -111,7 +123,7 @@ namespace osu.Game.Online.Chat
public static LinkDetails GetLinkDetails(string url)
{
var args = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
var args = url.Split('/', StringSplitOptions.RemoveEmptyEntries);
args[0] = args[0].TrimEnd(':');
switch (args[0])
@ -119,22 +131,42 @@ namespace osu.Game.Online.Chat
case "http":
case "https":
// length > 3 since all these links need another argument to work
if (args.Length > 3 && (args[1] == "osu.ppy.sh" || args[1] == "new.ppy.sh"))
if (args.Length > 3 && args[1].EndsWith(websiteRootUrl, StringComparison.OrdinalIgnoreCase))
{
var mainArg = args[3];
switch (args[2])
{
// old site only
case "b":
case "beatmaps":
return new LinkDetails(LinkAction.OpenBeatmap, args[3]);
{
string trimmed = mainArg.Split('?').First();
if (int.TryParse(trimmed, out var id))
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());
break;
}
case "s":
case "beatmapsets":
case "d":
return new LinkDetails(LinkAction.OpenBeatmapSet, args[3]);
{
if (args.Length > 4 && int.TryParse(args[4], out var id))
// https://osu.ppy.sh/beatmapsets/1154158#osu/2768184
return new LinkDetails(LinkAction.OpenBeatmap, id.ToString());
// https://osu.ppy.sh/beatmapsets/1154158#whatever
string trimmed = mainArg.Split('#').First();
if (int.TryParse(trimmed, out id))
return new LinkDetails(LinkAction.OpenBeatmapSet, id.ToString());
break;
}
case "u":
case "users":
return new LinkDetails(LinkAction.OpenUserProfile, args[3]);
return new LinkDetails(LinkAction.OpenUserProfile, mainArg);
}
}
@ -183,10 +215,9 @@ namespace osu.Game.Online.Chat
case "osump":
return new LinkDetails(LinkAction.JoinMultiplayerMatch, args[1]);
default:
return new LinkDetails(LinkAction.External, null);
}
return new LinkDetails(LinkAction.External, null);
}
private static MessageFormatterResult format(string toFormat, int startIndex = 0, int space = 3)
@ -259,8 +290,9 @@ namespace osu.Game.Online.Chat
public class LinkDetails
{
public LinkAction Action;
public string Argument;
public readonly LinkAction Action;
public readonly string Argument;
public LinkDetails(LinkAction action, string argument)
{

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -34,7 +35,7 @@ namespace osu.Game.Online.Chat
private Bindable<bool> notifyOnMention;
private Bindable<bool> notifyOnPM;
private Bindable<User> localUser;
private IBindable<User> localUser;
private readonly BindableList<Channel> joinedChannels = new BindableList<Channel>();
[BackgroundDependencyLoader]
@ -47,17 +48,25 @@ namespace osu.Game.Online.Chat
channelManager.JoinedChannels.BindTo(joinedChannels);
// Listen for new messages
joinedChannels.ItemsAdded += joinedChannels =>
{
foreach (var channel in joinedChannels)
channel.NewMessagesArrived += newMessagesArrived;
};
joinedChannels.CollectionChanged += channelsChanged;
}
joinedChannels.ItemsRemoved += leftChannels =>
private void channelsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
foreach (var channel in leftChannels)
channel.NewMessagesArrived -= newMessagesArrived;
};
case NotifyCollectionChangedAction.Add:
foreach (var channel in e.NewItems.Cast<Channel>())
channel.NewMessagesArrived += newMessagesArrived;
break;
case NotifyCollectionChangedAction.Remove:
foreach (var channel in e.OldItems.Cast<Channel>())
channel.NewMessagesArrived -= newMessagesArrived;
break;
}
}
private void newMessagesArrived(IEnumerable<Message> messages)

View File

@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
using osu.Game.Online.API;
using osu.Game.Users;
namespace osu.Game.Online.Chat
{
public class NowPlayingCommand : Component
{
[Resolved]
private IChannelPostTarget channelManager { get; set; }
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private Bindable<WorkingBeatmap> currentBeatmap { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
string verb;
BeatmapInfo beatmap;
switch (api.Activity.Value)
{
case UserActivity.SoloGame solo:
verb = "playing";
beatmap = solo.Beatmap;
break;
case UserActivity.Editing edit:
verb = "editing";
beatmap = edit.Beatmap;
break;
default:
verb = "listening to";
beatmap = currentBeatmap.Value.BeatmapInfo;
break;
}
var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[{api.WebsiteRootUrl}/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString();
channelManager.PostMessage($"is {verb} {beatmapString}", true);
Expire();
}
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Online.Chat
protected ChannelManager ChannelManager;
private DrawableChannel drawableChannel;
private StandAloneDrawableChannel drawableChannel;
private readonly bool postingTextbox;
@ -59,12 +59,13 @@ namespace osu.Game.Online.Chat
RelativeSizeAxes = Axes.X,
Height = textbox_height,
PlaceholderText = "type your message",
OnCommit = postMessage,
ReleaseFocusOnCommit = false,
HoldFocus = true,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
});
textbox.OnCommit += postMessage;
}
Channel.BindValueChanged(channelChanged);
@ -73,10 +74,12 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader(true)]
private void load(ChannelManager manager)
{
if (ChannelManager == null)
ChannelManager = manager;
ChannelManager ??= manager;
}
protected virtual StandAloneDrawableChannel CreateDrawableChannel(Channel channel) =>
new StandAloneDrawableChannel(channel);
private void postMessage(TextBox sender, bool newtext)
{
var text = textbox.Text.Trim();
@ -92,18 +95,6 @@ namespace osu.Game.Online.Chat
textbox.Text = string.Empty;
}
public void Contract()
{
this.FadeIn(300);
this.MoveToY(0, 500, Easing.OutQuint);
}
public void Expand()
{
this.FadeOut(200);
this.MoveToY(100, 500, Easing.In);
}
protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message);
private void channelChanged(ValueChangedEvent<Channel> e)
@ -112,14 +103,14 @@ namespace osu.Game.Online.Chat
if (e.NewValue == null) return;
AddInternal(drawableChannel = new StandAloneDrawableChannel(e.NewValue)
{
CreateChatLineAction = CreateMessage,
Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 }
});
drawableChannel = CreateDrawableChannel(e.NewValue);
drawableChannel.CreateChatLineAction = CreateMessage;
drawableChannel.Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 };
AddInternal(drawableChannel);
}
protected class StandAloneDrawableChannel : DrawableChannel
public class StandAloneDrawableChannel : DrawableChannel
{
public Func<Message, ChatLine> CreateChatLineAction;