1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-22 03:27:24 +08:00

Merge pull request #7227 from Craftplacer/chat-mention

Add notification support for private messages and username mentions
This commit is contained in:
Dean Herbert 2021-06-11 20:40:39 +09:00 committed by GitHub
commit 91e4397bd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 471 additions and 9 deletions

View File

@ -0,0 +1,240 @@
// 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 System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Users;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneMessageNotifier : OsuManualInputManagerTestScene
{
private User friend;
private Channel publicChannel;
private Channel privateMessageChannel;
private TestContainer testContainer;
private int messageIdCounter;
[SetUp]
public void Setup()
{
if (API is DummyAPIAccess daa)
{
daa.HandleRequest = dummyAPIHandleRequest;
}
friend = new User { Id = 0, Username = "Friend" };
publicChannel = new Channel { Id = 1, Name = "osu" };
privateMessageChannel = new Channel(friend) { Id = 2, Name = friend.Username, Type = ChannelType.PM };
Schedule(() =>
{
Child = testContainer = new TestContainer(new[] { publicChannel, privateMessageChannel })
{
RelativeSizeAxes = Axes.Both,
};
testContainer.ChatOverlay.Show();
});
}
private bool dummyAPIHandleRequest(APIRequest request)
{
switch (request)
{
case GetMessagesRequest messagesRequest:
messagesRequest.TriggerSuccess(new List<Message>(0));
return true;
case CreateChannelRequest createChannelRequest:
var apiChatChannel = new APIChatChannel
{
RecentMessages = new List<Message>(0),
ChannelID = (int)createChannelRequest.Channel.Id
};
createChannelRequest.TriggerSuccess(apiChatChannel);
return true;
case ListChannelsRequest listChannelsRequest:
listChannelsRequest.TriggerSuccess(new List<Channel>(1) { publicChannel });
return true;
case GetUpdatesRequest updatesRequest:
updatesRequest.TriggerSuccess(new GetUpdatesResponse
{
Messages = new List<Message>(0),
Presence = new List<Channel>(0)
});
return true;
case JoinChannelRequest joinChannelRequest:
joinChannelRequest.TriggerSuccess();
return true;
default:
return false;
}
}
[Test]
public void TestPublicChannelMention()
{
AddStep("switch to PMs", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel);
AddStep("receive public message", () => receiveMessage(friend, publicChannel, "Hello everyone"));
AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0);
AddStep("receive message containing mention", () => receiveMessage(friend, publicChannel, $"Hello {API.LocalUser.Value.Username.ToLowerInvariant()}!"));
AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1);
AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show());
AddStep("click notification", clickNotification<MessageNotifier.MentionNotification>);
AddAssert("chat overlay is open", () => testContainer.ChatOverlay.State.Value == Visibility.Visible);
AddAssert("public channel is selected", () => testContainer.ChannelManager.CurrentChannel.Value == publicChannel);
}
[Test]
public void TestPrivateMessageNotification()
{
AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel);
AddStep("receive PM", () => receiveMessage(friend, privateMessageChannel, $"Hello {API.LocalUser.Value.Username}"));
AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1);
AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show());
AddStep("click notification", clickNotification<MessageNotifier.PrivateMessageNotification>);
AddAssert("chat overlay is open", () => testContainer.ChatOverlay.State.Value == Visibility.Visible);
AddAssert("PM channel is selected", () => testContainer.ChannelManager.CurrentChannel.Value == privateMessageChannel);
}
[Test]
public void TestNoNotificationWhenPMChannelOpen()
{
AddStep("switch to PMs", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel);
AddStep("receive PM", () => receiveMessage(friend, privateMessageChannel, "you're reading this, right?"));
AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0);
}
[Test]
public void TestNoNotificationWhenMentionedInOpenPublicChannel()
{
AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel);
AddStep("receive mention", () => receiveMessage(friend, publicChannel, $"{API.LocalUser.Value.Username.ToUpperInvariant()} has been reading this"));
AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0);
}
[Test]
public void TestNoNotificationOnSelfMention()
{
AddStep("switch to PM channel", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel);
AddStep("receive self-mention", () => receiveMessage(API.LocalUser.Value, publicChannel, $"my name is {API.LocalUser.Value.Username}"));
AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0);
}
[Test]
public void TestNoNotificationOnPMFromSelf()
{
AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel);
AddStep("receive PM from self", () => receiveMessage(API.LocalUser.Value, privateMessageChannel, "hey hey"));
AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0);
}
[Test]
public void TestNotificationsNotFiredTwice()
{
AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel);
AddStep("receive same PM twice", () =>
{
var message = createMessage(friend, privateMessageChannel, "hey hey");
privateMessageChannel.AddNewMessages(message, message);
});
AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show());
AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1);
}
private void receiveMessage(User sender, Channel channel, string content) => channel.AddNewMessages(createMessage(sender, channel, content));
private Message createMessage(User sender, Channel channel, string content) => new Message(messageIdCounter++)
{
Content = content,
Sender = sender,
ChannelId = channel.Id
};
private void clickNotification<T>() where T : Notification
{
var notification = testContainer.NotificationOverlay.ChildrenOfType<T>().Single();
InputManager.MoveMouseTo(notification);
InputManager.Click(MouseButton.Left);
}
private class TestContainer : Container
{
[Cached]
public ChannelManager ChannelManager { get; } = new ChannelManager();
[Cached]
public NotificationOverlay NotificationOverlay { get; } = new NotificationOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
};
[Cached]
public ChatOverlay ChatOverlay { get; } = new ChatOverlay();
private readonly MessageNotifier messageNotifier = new MessageNotifier();
private readonly Channel[] channels;
public TestContainer(Channel[] channels)
{
this.channels = channels;
}
[BackgroundDependencyLoader]
private void load()
{
Children = new Drawable[]
{
ChannelManager,
ChatOverlay,
NotificationOverlay,
messageNotifier,
};
((BindableList<Channel>)ChannelManager.AvailableChannels).AddRange(channels);
foreach (var channel in channels)
ChannelManager.JoinChannel(channel);
}
}
}
}

View File

@ -61,6 +61,9 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.ShowOnlineExplicitContent, false);
SetDefault(OsuSetting.NotifyOnUsernameMentioned, true);
SetDefault(OsuSetting.NotifyOnPrivateMessage, true);
// Audio
SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01);
@ -259,6 +262,8 @@ namespace osu.Game.Configuration
ScalingSizeY,
UIScale,
IntroSequence,
NotifyOnUsernameMentioned,
NotifyOnPrivateMessage,
UIHoldActivationDelay,
HitLighting,
MenuBackgroundSource,

View File

@ -11,11 +11,11 @@ namespace osu.Game.Online.API.Requests
{
public class CreateChannelRequest : APIRequest<APIChatChannel>
{
private readonly Channel channel;
public readonly Channel Channel;
public CreateChannelRequest(Channel channel)
{
this.channel = channel;
Channel = channel;
}
protected override WebRequest CreateWebRequest()
@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests
req.Method = HttpMethod.Post;
req.AddParameter("type", $"{ChannelType.PM}");
req.AddParameter("target_id", $"{channel.Users.First().Id}");
req.AddParameter("target_id", $"{Channel.Users.First().Id}");
return req;
}

View File

@ -63,5 +63,7 @@ namespace osu.Game.Online.Chat
// ReSharper disable once ImpureMethodCallOnReadonlyValueField
public override int GetHashCode() => Id.GetHashCode();
public override string ToString() => $"[{ChannelId}] ({Id}) {Sender}: {Content}";
}
}

View File

@ -0,0 +1,181 @@
// 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 System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Online.API;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Users;
namespace osu.Game.Online.Chat
{
/// <summary>
/// Component that handles creating and posting notifications for incoming messages.
/// </summary>
public class MessageNotifier : Component
{
[Resolved]
private NotificationOverlay notifications { get; set; }
[Resolved]
private ChatOverlay chatOverlay { get; set; }
[Resolved]
private ChannelManager channelManager { get; set; }
private Bindable<bool> notifyOnUsername;
private Bindable<bool> notifyOnPrivateMessage;
private readonly IBindable<User> localUser = new Bindable<User>();
private readonly IBindableList<Channel> joinedChannels = new BindableList<Channel>();
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, IAPIProvider api)
{
notifyOnUsername = config.GetBindable<bool>(OsuSetting.NotifyOnUsernameMentioned);
notifyOnPrivateMessage = config.GetBindable<bool>(OsuSetting.NotifyOnPrivateMessage);
localUser.BindTo(api.LocalUser);
joinedChannels.BindTo(channelManager.JoinedChannels);
}
protected override void LoadComplete()
{
base.LoadComplete();
joinedChannels.BindCollectionChanged(channelsChanged, true);
}
private void channelsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var channel in e.NewItems.Cast<Channel>())
channel.NewMessagesArrived += checkNewMessages;
break;
case NotifyCollectionChangedAction.Remove:
foreach (var channel in e.OldItems.Cast<Channel>())
channel.NewMessagesArrived -= checkNewMessages;
break;
}
}
private void checkNewMessages(IEnumerable<Message> messages)
{
if (!messages.Any())
return;
var channel = channelManager.JoinedChannels.SingleOrDefault(c => c.Id == messages.First().ChannelId);
if (channel == null)
return;
// Only send notifications, if ChatOverlay and the target channel aren't visible.
if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel)
return;
foreach (var message in messages.OrderByDescending(m => m.Id))
{
// ignore messages that already have been read
if (message.Id <= channel.LastReadId)
return;
if (message.Sender.Id == localUser.Value.Id)
continue;
// check for private messages first to avoid both posting two notifications about the same message
if (checkForPMs(channel, message))
continue;
checkForMentions(channel, message);
}
}
/// <summary>
/// Checks whether the user enabled private message notifications and whether specified <paramref name="message"/> is a direct message.
/// </summary>
/// <param name="channel">The channel associated to the <paramref name="message"/></param>
/// <param name="message">The message to be checked</param>
/// <returns>Whether a notification was fired.</returns>
private bool checkForPMs(Channel channel, Message message)
{
if (!notifyOnPrivateMessage.Value || channel.Type != ChannelType.PM)
return false;
notifications.Post(new PrivateMessageNotification(message.Sender.Username, channel));
return true;
}
private void checkForMentions(Channel channel, Message message)
{
if (!notifyOnUsername.Value || !checkContainsUsername(message.Content, localUser.Value.Username)) return;
notifications.Post(new MentionNotification(message.Sender.Username, channel));
}
/// <summary>
/// Checks if <paramref name="message"/> contains <paramref name="username"/>.
/// This will match against the case where underscores are used instead of spaces (which is how osu-stable handles usernames with spaces).
/// </summary>
private static bool checkContainsUsername(string message, string username) => message.Contains(username, StringComparison.OrdinalIgnoreCase) || message.Contains(username.Replace(' ', '_'), StringComparison.OrdinalIgnoreCase);
public class PrivateMessageNotification : OpenChannelNotification
{
public PrivateMessageNotification(string username, Channel channel)
: base(channel)
{
Icon = FontAwesome.Solid.Envelope;
Text = $"You received a private message from '{username}'. Click to read it!";
}
}
public class MentionNotification : OpenChannelNotification
{
public MentionNotification(string username, Channel channel)
: base(channel)
{
Icon = FontAwesome.Solid.At;
Text = $"Your name was mentioned in chat by '{username}'. Click to find out why!";
}
}
public abstract class OpenChannelNotification : SimpleNotification
{
protected OpenChannelNotification(Channel channel)
{
this.channel = channel;
}
private readonly Channel channel;
public override bool IsImportant => false;
[BackgroundDependencyLoader]
private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay, ChannelManager channelManager)
{
IconBackgound.Colour = colours.PurpleDark;
Activated = delegate
{
notificationOverlay.Hide();
chatOverlay.Show();
channelManager.CurrentChannel.Value = channel;
return true;
};
}
}
}
}

View File

@ -728,6 +728,7 @@ namespace osu.Game
var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true);
loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true);
loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true);
loadComponentSingleFile(new MessageNotifier(), AddInternal, true);
loadComponentSingleFile(Settings = new SettingsOverlay { GetToolbarHeight = () => ToolbarOffset }, leftFloatingOverlayContent.Add, true);
var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true);
loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true);

View File

@ -6,18 +6,18 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osuTK.Graphics;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Online.Chat;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Chat;
using osuTK.Graphics;
namespace osu.Game.Overlays.Chat
{

View File

@ -0,0 +1,32 @@
// 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.Graphics;
using osu.Game.Configuration;
namespace osu.Game.Overlays.Settings.Sections.Online
{
public class AlertsAndPrivacySettings : SettingsSubsection
{
protected override string Header => "Alerts and Privacy";
[BackgroundDependencyLoader]
private void load(OsuConfigManager config)
{
Children = new Drawable[]
{
new SettingsCheckbox
{
LabelText = "Show a notification when someone mentions your name",
Current = config.GetBindable<bool>(OsuSetting.NotifyOnUsernameMentioned)
},
new SettingsCheckbox
{
LabelText = "Show a notification when you receive a private message",
Current = config.GetBindable<bool>(OsuSetting.NotifyOnPrivateMessage)
},
};
}
}
}

View File

@ -21,6 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections
Children = new Drawable[]
{
new WebSettings(),
new AlertsAndPrivacySettings(),
new IntegrationSettings()
};
}