More & localized notifications

This commit is contained in:
LittleSheep 2025-05-28 01:50:14 +08:00
parent 39d9d8a839
commit bb739c1d90
11 changed files with 199 additions and 30 deletions

View File

@ -114,7 +114,7 @@ public class NotificationService
_db.Add(notification); _db.Add(notification);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
if (!isSilent) _ = DeliveryNotification(notification).ConfigureAwait(false); if (!isSilent) _ = DeliveryNotification(notification);
return notification; return notification;
} }

View File

@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm; using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Chat; namespace DysonNetwork.Sphere.Chat;
@ -19,7 +21,8 @@ public class ChatRoomController(
RealmService rs, RealmService rs,
ActionLogService als, ActionLogService als,
NotificationService nty, NotificationService nty,
RelationshipService rels RelationshipService rels,
IStringLocalizer<NotificationResource> localizer
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
@ -74,7 +77,7 @@ public class ChatRoomController(
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
if (relatedUser is null) if (relatedUser is null)
return BadRequest("Related user was not found"); return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked)) if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
return StatusCode(403, "You cannot create direct message with a user that blocked you."); return StatusCode(403, "You cannot create direct message with a user that blocked you.");
@ -121,7 +124,8 @@ public class ChatRoomController(
); );
var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId); var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
await _SendInviteNotify(invitedMember); invitedMember.ChatRoom = dmRoom;
await _SendInviteNotify(invitedMember, currentUser);
return Ok(dmRoom); return Ok(dmRoom);
} }
@ -377,7 +381,7 @@ public class ChatRoomController(
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found"); if (relatedUser is null) return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked)) if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
return StatusCode(403, "You cannot invite a user that blocked you."); return StatusCode(403, "You cannot invite a user that blocked you.");
@ -428,7 +432,8 @@ public class ChatRoomController(
db.ChatMembers.Add(newMember); db.ChatMembers.Add(newMember);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await _SendInviteNotify(newMember); newMember.ChatRoom = chatRoom;
await _SendInviteNotify(newMember, currentUser);
als.CreateActionLogFromRequest( als.CreateActionLogFromRequest(
ActionLogType.ChatroomInvite, ActionLogType.ChatroomInvite,
@ -680,9 +685,9 @@ public class ChatRoomController(
return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat."); return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat.");
} }
member.LeaveAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow); member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
crs.PurgeRoomMembersCache(roomId); await crs.PurgeRoomMembersCache(roomId);
als.CreateActionLogFromRequest( als.CreateActionLogFromRequest(
ActionLogType.ChatroomLeave, ActionLogType.ChatroomLeave,
@ -692,9 +697,14 @@ public class ChatRoomController(
return NoContent(); return NoContent();
} }
private async Task _SendInviteNotify(ChatMember member) private async Task _SendInviteNotify(ChatMember member, Account.Account sender)
{ {
await nty.SendNotification(member.Account, "invites.chats", "New Chat Invitation", null, string title = localizer["ChatInviteTitle"];
$"You just got invited to join {member.ChatRoom.Name}");
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
? localizer["ChatInviteDirectBody", sender.Nick]
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
await nty.SendNotification(member.Account, "invites.chats", title, null, body);
} }
} }

View File

@ -257,6 +257,7 @@ public class PostController(
var post = await db.Posts var post = await db.Posts
.Where(e => e.Id == id) .Where(e => e.Id == id)
.Include(e => e.Publisher) .Include(e => e.Publisher)
.ThenInclude(e => e.Account)
.FilterWithVisibility(currentUser, userFriends) .FilterWithVisibility(currentUser, userFriends)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
@ -274,7 +275,14 @@ public class PostController(
PostId = post.Id, PostId = post.Id,
AccountId = currentUser.Id AccountId = currentUser.Id
}; };
var isRemoving = await ps.ModifyPostVotes(post, reaction, isExistingReaction, isSelfReact); var isRemoving = await ps.ModifyPostVotes(
post,
reaction,
currentUser,
post.Publisher.Account,
isExistingReaction,
isSelfReact
);
if (isRemoving) return NoContent(); if (isRemoving) return NoContent();

View File

@ -1,12 +1,21 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Activity; using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Post; namespace DysonNetwork.Sphere.Post;
public class PostService(AppDatabase db, FileService fs, ActivityService act) public class PostService(
AppDatabase db,
FileService fs,
ActivityService act,
IStringLocalizer<NotificationResource> localizer,
NotificationService nty
)
{ {
public static List<Post> TruncatePostContent(List<Post> input) public static List<Post> TruncatePostContent(List<Post> input)
{ {
@ -158,8 +167,18 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
/// </summary> /// </summary>
/// <param name="post">Post that modifying</param> /// <param name="post">Post that modifying</param>
/// <param name="reaction">The new / target reaction adding / removing</param> /// <param name="reaction">The new / target reaction adding / removing</param>
/// <param name="op">The original poster account of this post</param>
/// <param name="isRemoving">Indicate this operation is adding / removing</param> /// <param name="isRemoving">Indicate this operation is adding / removing</param>
public async Task<bool> ModifyPostVotes(Post post, PostReaction reaction, bool isRemoving, bool isSelfReact) /// <param name="isSelfReact">Indicate this reaction is by the original post himself</param>
/// <param name="sender">The account that creates this reaction</param>
public async Task<bool> ModifyPostVotes(
Post post,
PostReaction reaction,
Account.Account sender,
Account.Account? op,
bool isRemoving,
bool isSelfReact
)
{ {
var isExistingReaction = await db.Set<PostReaction>() var isExistingReaction = await db.Set<PostReaction>()
.AnyAsync(r => r.PostId == post.Id && r.AccountId == reaction.AccountId); .AnyAsync(r => r.PostId == post.Id && r.AccountId == reaction.AccountId);
@ -197,6 +216,21 @@ public class PostService(AppDatabase db, FileService fs, ActivityService act)
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (!isSelfReact && op is not null)
{
await nty.SendNotification(
op,
"posts.reactions.new",
localizer["PostReactTitle", sender.Nick],
null,
string.IsNullOrWhiteSpace(post.Title)
? localizer["PostReactBody", sender.Nick, reaction.Symbol]
: localizer["PostReactContentBody", sender.Nick, reaction.Symbol,
post.Title]
);
}
return isRemoving; return isRemoving;
} }

View File

@ -1,9 +1,13 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Publisher; namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(AppDatabase db, NotificationService nty) public class PublisherSubscriptionService(
AppDatabase db,
NotificationService nty,
IStringLocalizer<Notification> localizer)
{ {
/// <summary> /// <summary>
/// Checks if a subscription exists between the account and publisher /// Checks if a subscription exists between the account and publisher
@ -48,12 +52,11 @@ public class PublisherSubscriptionService(AppDatabase db, NotificationService nt
return 0; return 0;
// Create notification data // Create notification data
var title = $"@{post.Publisher.Name} Posted"; var message = !string.IsNullOrEmpty(post.Description)
var message = !string.IsNullOrEmpty(post.Title) ? post.Description?.Length > 40 ? post.Description[..37] + "..." : post.Description
? post.Title : post.Content?.Length > 100
: (post.Content?.Length > 100
? string.Concat(post.Content.AsSpan(0, 97), "...") ? string.Concat(post.Content.AsSpan(0, 97), "...")
: post.Content); : post.Content;
// Data to include with the notification // Data to include with the notification
var data = new Dictionary<string, object> var data = new Dictionary<string, object>
@ -71,8 +74,8 @@ public class PublisherSubscriptionService(AppDatabase db, NotificationService nt
await nty.SendNotification( await nty.SendNotification(
subscription.Account, subscription.Account,
"posts.new", "posts.new",
title, localizer["New post from {0}", post.Publisher.Name],
post.Description?.Length > 40 ? post.Description[..37] + "..." : post.Description, string.IsNullOrWhiteSpace(post.Title) ? null : post.Title,
message, message,
data data
); );
@ -169,9 +172,7 @@ public class PublisherSubscriptionService(AppDatabase db, NotificationService nt
{ {
var subscription = await GetSubscriptionAsync(accountId, publisherId); var subscription = await GetSubscriptionAsync(accountId, publisherId);
if (subscription is not { Status: SubscriptionStatus.Active }) if (subscription is not { Status: SubscriptionStatus.Active })
{
return false; return false;
}
subscription.Status = SubscriptionStatus.Cancelled; subscription.Status = SubscriptionStatus.Cancelled;
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View File

@ -111,6 +111,7 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs, Re
); );
member.Account = relatedUser; member.Account = relatedUser;
member.Realm = realm;
await rs.SendInviteNotify(member); await rs.SendInviteNotify(member);
return Ok(member); return Ok(member);

View File

@ -1,21 +1,28 @@
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
namespace DysonNetwork.Sphere.Realm; namespace DysonNetwork.Sphere.Realm;
public class RealmService(AppDatabase db, NotificationService nty) public class RealmService(AppDatabase db, NotificationService nty, IStringLocalizer<NotificationResource> localizer)
{ {
public async Task SendInviteNotify(RealmMember member) public async Task SendInviteNotify(RealmMember member)
{ {
await nty.SendNotification(member.Account, "invites.realms", "New Realm Invitation", null, await nty.SendNotification(
$"You just got invited to join {member.Realm.Name}"); member.Account,
"invites.realms",
localizer["RealmInviteTitle"],
null,
localizer["RealmInviteBody", member.Realm.Name]
);
} }
public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, params RealmMemberRole[] requiredRoles) public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, params RealmMemberRole[] requiredRoles)
{ {
if (requiredRoles.Length == 0) if (requiredRoles.Length == 0)
return false; return false;
var maxRequiredRole = requiredRoles.Max(); var maxRequiredRole = requiredRoles.Max();
var member = await db.RealmMembers var member = await db.RealmMembers
.FirstOrDefaultAsync(m => m.RealmId == realmId && m.AccountId == accountId); .FirstOrDefaultAsync(m => m.RealmId == realmId && m.AccountId == accountId);

View File

@ -44,5 +44,59 @@ namespace DysonNetwork.Sphere.Resources.Localization {
resourceCulture = value; resourceCulture = value;
} }
} }
internal static string ChatInviteTitle {
get {
return ResourceManager.GetString("ChatInviteTitle", resourceCulture);
}
}
internal static string ChatInviteBody {
get {
return ResourceManager.GetString("ChatInviteBody", resourceCulture);
}
}
internal static string ChatInviteDirectBody {
get {
return ResourceManager.GetString("ChatInviteDirectBody", resourceCulture);
}
}
internal static string RealmInviteTitle {
get {
return ResourceManager.GetString("RealmInviteTitle", resourceCulture);
}
}
internal static string RealmInviteBody {
get {
return ResourceManager.GetString("RealmInviteBody", resourceCulture);
}
}
internal static string PostSubscriptionTitle {
get {
return ResourceManager.GetString("PostSubscriptionTitle", resourceCulture);
}
}
internal static string PostReactTitle {
get {
return ResourceManager.GetString("PostReactTitle", resourceCulture);
}
}
internal static string PostReactBody {
get {
return ResourceManager.GetString("PostReactBody", resourceCulture);
}
}
internal static string PostReactContentBody {
get {
return ResourceManager.GetString("PostReactContentBody", resourceCulture);
}
}
} }
} }

View File

@ -18,4 +18,31 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="ChatInviteTitle" xml:space="preserve">
<value>New Chat Invitation</value>
</data>
<data name="ChatInviteBody" xml:space="preserve">
<value>You just got invited to join {0}</value>
</data>
<data name="ChatInviteDirectBody" xml:space="preserve">
<value>{0} sent an direct message invitation to you</value>
</data>
<data name="RealmInviteTitle" xml:space="preserve">
<value>New Realm Invitation</value>
</data>
<data name="RealmInviteBody" xml:space="preserve">
<value>You just got invited to join {0}</value>
</data>
<data name="PostSubscriptionTitle" xml:space="preserve">
<value>{0} just posted</value>
</data>
<data name="PostReactTitle" xml:space="preserve">
<value>{0} reacted your post</value>
</data>
<data name="PostReactBody" xml:space="preserve">
<value>{0} added a reaction {1} to your post</value>
</data>
<data name="PostReactContentBody" xml:space="preserve">
<value>{0} added a reaction {1} to your post {2}</value>
</data>
</root> </root>

View File

@ -11,4 +11,31 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="ChatInviteTitle" xml:space="preserve">
<value>新聊天邀请</value>
</data>
<data name="ChatInviteBody" xml:space="preserve">
<value>你刚被邀请加入聊天 {}</value>
</data>
<data name="ChatInviteDirectBody" xml:space="preserve">
<value>{0} 向你发送了一个私聊邀请</value>
</data>
<data name="RealmInviteTitle" xml:space="preserve">
<value>新加入领域邀请</value>
</data>
<data name="RealmInviteBody" xml:space="preserve">
<value>你刚被邀请加入领域 {0}</value>
</data>
<data name="PostSubscriptionTitle" xml:space="preserve">
<value>{0} 有新帖子</value>
</data>
<data name="PostReactTitle" xml:space="preserve">
<value>{0} 反应了你的帖子</value>
</data>
<data name="PostReactBody" xml:space="preserve">
<value>{0} 给你的帖子添加了一个 {1} 的反应</value>
</data>
<data name="PostReactContentBody" xml:space="preserve">
<value>{0} 给你的帖子添加了一个 {1} 的反应 {2}</value>
</data>
</root> </root>

View File

@ -101,7 +101,7 @@
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexedValue">False</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexedValue">False</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmails_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmails_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">False</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">True</s:Boolean>