Enumerate(CandidateSet candidates)
+ {
+ for (var i = 0; i < candidates.Count; i++) yield return candidates[i];
+ }
+}
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class PublicPreviewRouteFilterAttribute : Attribute;
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor b/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor
new file mode 100644
index 00000000..1b091e88
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor
@@ -0,0 +1,34 @@
+@using Iceshrimp.Backend.Components.PublicPreview.Schemas
+
+
+
+
+
+
+
+ @if (Note.Text != null)
+ {
+ if (Note.Cw != null)
+ {
+
+ @Note.Cw
+ @Note.Text
+
+ }
+ else
+ {
+ @Note.Text
+ }
+ }
+
+
+@code {
+ [Parameter, EditorRequired] public required PreviewNote Note { get; set; }
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor.css b/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor.css
new file mode 100644
index 00000000..8b46be7e
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/NoteComponent.razor.css
@@ -0,0 +1,11 @@
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ background-color: #f7f7f7;
+ background-color: var(--background-alt);
+ padding: 10px;
+ margin: 1em 0;
+ border-radius: 6px;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs
new file mode 100644
index 00000000..83138ba5
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/MfmRenderer.cs
@@ -0,0 +1,23 @@
+using Iceshrimp.Backend.Core.Configuration;
+using Iceshrimp.Backend.Core.Database.Tables;
+using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
+using Iceshrimp.Parsing;
+using Microsoft.AspNetCore.Components;
+using Microsoft.Extensions.Options;
+
+namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
+
+public class MfmRenderer(IOptions config)
+{
+ private readonly MfmConverter _converter = new(config);
+
+ public async Task Render(
+ string? text, string? host, List mentions, List emoji, string rootElement
+ )
+ {
+ if (text is null) return null;
+ var parsed = Mfm.parse(text);
+ var serialized = await _converter.ToHtmlAsync(parsed, mentions, host, emoji: emoji, rootElement: rootElement);
+ return new MarkupString(serialized);
+ }
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs
new file mode 100644
index 00000000..1c20bfba
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/NoteRenderer.cs
@@ -0,0 +1,118 @@
+using Iceshrimp.Backend.Components.PublicPreview.Schemas;
+using Iceshrimp.Backend.Core.Configuration;
+using Iceshrimp.Backend.Core.Database;
+using Iceshrimp.Backend.Core.Database.Tables;
+using Iceshrimp.Backend.Core.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
+
+public class NoteRenderer(
+ DatabaseContext db,
+ UserRenderer userRenderer,
+ MfmRenderer mfm,
+ IOptions instance,
+ IOptionsSnapshot security
+)
+{
+ public async Task RenderOne(Note? note)
+ {
+ if (note == null) return null;
+
+ var allNotes = ((Note?[]) [note, note.Reply, note.Renote]).NotNull().ToList();
+
+ var mentions = await GetMentions(allNotes);
+ var emoji = await GetEmoji(allNotes);
+ var users = await GetUsers(allNotes);
+ var attachments = await GetAttachments(allNotes);
+
+ return await Render(note, users, mentions, emoji, attachments);
+ }
+
+ private async Task Render(
+ Note note, List users, Dictionary> mentions,
+ Dictionary> emoji, Dictionary?> attachments
+ )
+ {
+ var res = new PreviewNote
+ {
+ User = users.First(p => p.Id == note.User.Id),
+ Text = await mfm.Render(note.Text, note.User.Host, mentions[note.Id], emoji[note.Id], "span"),
+ Cw = note.Cw,
+ RawText = note.Text,
+ QuoteUrl = note.Renote?.Url ?? note.Renote?.Uri ?? note.Renote?.GetPublicUriOrNull(instance.Value),
+ QuoteInaccessible = note.Renote?.VisibilityIsPublicOrHome == false,
+ Attachments = attachments[note.Id],
+ CreatedAt = note.CreatedAt.ToDisplayStringTz(),
+ UpdatedAt = note.UpdatedAt?.ToDisplayStringTz()
+ };
+
+ return res;
+ }
+
+ private async Task>> GetMentions(List notes)
+ {
+ var mentions = notes.SelectMany(n => n.Mentions).Distinct().ToList();
+ if (mentions.Count == 0) return notes.ToDictionary>(p => p.Id, _ => []);
+
+ var users = await db.Users.Where(p => mentions.Contains(p.Id))
+ .ToDictionaryAsync(p => p.Id,
+ p => new Note.MentionedUser
+ {
+ Host = p.Host,
+ Uri = p.Uri ?? p.GetPublicUri(instance.Value),
+ Url = p.UserProfile?.Url,
+ Username = p.Username
+ });
+ return notes.ToDictionary(p => p.Id,
+ p => users.Where(u => p.Mentions.Contains(u.Key)).Select(u => u.Value).ToList());
+ }
+
+ private async Task>> GetEmoji(List notes)
+ {
+ var ids = notes.SelectMany(n => n.Emojis).Distinct().ToList();
+ if (ids.Count == 0) return notes.ToDictionary>(p => p.Id, _ => []);
+
+ var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
+ return notes.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
+ }
+
+ private async Task> GetUsers(List notes)
+ {
+ if (notes is []) return [];
+ return await userRenderer.RenderMany(notes.Select(p => p.User).Distinct().ToList());
+ }
+
+ private async Task?>> GetAttachments(List notes)
+ {
+ if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
+ return notes.ToDictionary?>(p => p.Id,
+ p => p.FileIds is [] ? null : []);
+
+ var ids = notes.SelectMany(p => p.FileIds).ToList();
+ var files = await db.DriveFiles.Where(p => ids.Contains(p.Id)).ToListAsync();
+ return notes.ToDictionary?>(p => p.Id,
+ p => files
+ .Where(f => p.FileIds.Contains(f.Id))
+ .Select(f => new PreviewAttachment
+ {
+ MimeType = f.Type,
+ Url = f.AccessUrl,
+ Name = f.Name,
+ Alt = f.Comment
+ })
+ .ToList());
+ }
+
+ public async Task> RenderMany(List notes)
+ {
+ if (notes is []) return [];
+ var allNotes = notes.SelectMany(p => [p, p.Renote, p.Reply]).NotNull().Distinct().ToList();
+ var users = await GetUsers(allNotes);
+ var mentions = await GetMentions(allNotes);
+ var emoji = await GetEmoji(allNotes);
+ var attachments = await GetAttachments(allNotes);
+ return await notes.Select(p => Render(p, users, mentions, emoji, attachments)).AwaitAllAsync().ToListAsync();
+ }
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs b/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs
new file mode 100644
index 00000000..cc74483a
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/Renderers/UserRenderer.cs
@@ -0,0 +1,68 @@
+using Iceshrimp.Backend.Components.PublicPreview.Schemas;
+using Iceshrimp.Backend.Core.Configuration;
+using Iceshrimp.Backend.Core.Database;
+using Iceshrimp.Backend.Core.Database.Tables;
+using Iceshrimp.Backend.Core.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Iceshrimp.Backend.Components.PublicPreview.Renderers;
+
+public class UserRenderer(
+ DatabaseContext db,
+ MfmRenderer mfm,
+ IOptions instance,
+ IOptionsSnapshot security
+)
+{
+ public async Task RenderOne(User? user)
+ {
+ if (user == null) return null;
+ var emoji = await GetEmoji([user]);
+ return await Render(user, emoji);
+ }
+
+ private async Task Render(User user, Dictionary> emoji)
+ {
+ var mentions = user.UserProfile?.Mentions ?? [];
+
+ // @formatter:off
+ var res = new PreviewUser
+ {
+ Id = user.Id,
+ Username = user.Username,
+ Host = user.Host ?? instance.Value.AccountDomain,
+ Url = user.UserProfile?.Url ?? user.Uri ?? user.PublicUrlPath,
+ AvatarUrl = user.AvatarUrl ?? user.IdenticonUrlPath,
+ BannerUrl = user.BannerUrl,
+ RawDisplayName = user.DisplayName,
+ DisplayName = await mfm.Render(user.DisplayName, user.Host, mentions, emoji[user.Id], "span"),
+ Bio = await mfm.Render(user.UserProfile?.Description, user.Host, mentions, emoji[user.Id], "span"),
+ MovedToUri = user.MovedToUri
+ };
+ // @formatter:on
+
+ if (security.Value.PublicPreview is Enums.PublicPreview.RestrictedNoMedia)
+ {
+ res.AvatarUrl = user.IdenticonUrlPath;
+ res.BannerUrl = null;
+ }
+
+ return res;
+ }
+
+ private async Task>> GetEmoji(List users)
+ {
+ var ids = users.SelectMany(n => n.Emojis).Distinct().ToList();
+ if (ids.Count == 0) return users.ToDictionary>(p => p.Id, _ => []);
+
+ var emoji = await db.Emojis.Where(p => ids.Contains(p.Id)).ToListAsync();
+ return users.ToDictionary(p => p.Id, p => emoji.Where(e => p.Emojis.Contains(e.Id)).ToList());
+ }
+
+ public async Task> RenderMany(List users)
+ {
+ var emoji = await GetEmoji(users);
+ return await users.Select(p => Render(p, emoji)).AwaitAllAsync().ToListAsync();
+ }
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewNote.cs b/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewNote.cs
new file mode 100644
index 00000000..76c99cf5
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewNote.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
+
+public class PreviewNote
+{
+ public required PreviewUser User;
+ public required string? RawText;
+ public required MarkupString? Text;
+ public required string? Cw;
+ public required string? QuoteUrl;
+ public required bool QuoteInaccessible;
+ public required List? Attachments;
+ public required string CreatedAt;
+ public required string? UpdatedAt;
+}
+
+public class PreviewAttachment
+{
+ public required string MimeType;
+ public required string Url;
+ public required string Name;
+ public required string? Alt;
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewUser.cs b/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewUser.cs
new file mode 100644
index 00000000..84c1171d
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/Schemas/PreviewUser.cs
@@ -0,0 +1,17 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Iceshrimp.Backend.Components.PublicPreview.Schemas;
+
+public class PreviewUser
+{
+ public required string Id;
+ public required string? RawDisplayName;
+ public required MarkupString? DisplayName;
+ public required MarkupString? Bio;
+ public required string Username;
+ public required string Host;
+ public required string Url;
+ public required string AvatarUrl;
+ public required string? BannerUrl;
+ public required string? MovedToUri;
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor b/Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor
new file mode 100644
index 00000000..d4d279f7
--- /dev/null
+++ b/Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor
@@ -0,0 +1,32 @@
+@using Iceshrimp.Backend.Components.PublicPreview.Schemas
+
+@if (Link)
+{
+
+
+
+
+
+
@@@User.Username@@@User.Host
+
+}
+else
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired] public required PreviewUser User { get; set; }
+ [Parameter] public bool Link { get; set; } = true;
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Pages/Note.cshtml.css b/Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor.css
similarity index 70%
rename from Iceshrimp.Backend/Pages/Note.cshtml.css
rename to Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor.css
index 8481cd32..d8400f88 100644
--- a/Iceshrimp.Backend/Pages/Note.cshtml.css
+++ b/Iceshrimp.Backend/Components/PublicPreview/UserComponent.razor.css
@@ -1,15 +1,3 @@
-.content {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- background-color: #f7f7f7;
- background-color: var(--background-alt);
- padding: 10px;
- margin: 1em 0;
- border-radius: 6px;
- overflow: hidden;
-}
-
.user {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(3em, 3em));
@@ -45,4 +33,8 @@
.display-name {
color: var(--text-bright);
+}
+
+.host {
+ color: var(--text-dim);
}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/RootComponent.razor b/Iceshrimp.Backend/Components/RootComponent.razor
new file mode 100644
index 00000000..e9cbf8fc
--- /dev/null
+++ b/Iceshrimp.Backend/Components/RootComponent.razor
@@ -0,0 +1,38 @@
+@using System.Text.Encodings.Web
+@using Iceshrimp.Backend.Core.Configuration
+@using Iceshrimp.Backend.Core.Middleware
+@using Microsoft.Extensions.Options
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@inject IOptions Instance
+@preservewhitespace true
+@attribute [RazorSsr]
+
+
+
+
+
+
+
+
+
+ Iceshrimp.NET
+
+
+
+
+
+
+
+
+
+
+
+@code {
+ [CascadingParameter] public required HttpContext Context { get; set; }
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Components/RootComponent.razor.css b/Iceshrimp.Backend/Components/RootComponent.razor.css
new file mode 100644
index 00000000..9618dd9a
--- /dev/null
+++ b/Iceshrimp.Backend/Components/RootComponent.razor.css
@@ -0,0 +1,3 @@
+.float-right {
+ float: right;
+}
\ No newline at end of file
diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs
index 3da61447..1e430ce0 100644
--- a/Iceshrimp.Backend/Core/Database/Tables/User.cs
+++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs
@@ -624,9 +624,11 @@ public class User : IEntity
: throw new Exception("Cannot access PublicUri for remote user");
public string GetPublicUrl(string webDomain) => Host == null
- ? $"https://{webDomain}/@{Username}"
+ ? $"https://{webDomain}{PublicUrlPath}"
: throw new Exception("Cannot access PublicUrl for remote user");
+ [Projectable] public string PublicUrlPath => $"/@{Username}";
+
public string GetIdenticonUrl(string webDomain) => $"https://{webDomain}{IdenticonUrlPath}";
}
diff --git a/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs b/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs
index e3a88c7e..806e1e28 100644
--- a/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs
+++ b/Iceshrimp.Backend/Core/Extensions/MvcBuilderExtensions.cs
@@ -101,11 +101,6 @@ public static class MvcBuilderExtensions
return builder;
}
-
- public static IMvcBuilder AddRouteOverrides(this IMvcBuilder builder)
- {
- return builder.AddRazorPagesOptions(o => { o.Conventions.AddPageRoute("/User", "@{user}@{host}"); });
- }
}
///
diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs
index 27304902..f61f71b1 100644
--- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs
+++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs
@@ -1,6 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.RateLimiting;
using System.Xml.Linq;
+using Iceshrimp.Backend.Components.PublicPreview.Attributes;
+using Iceshrimp.Backend.Components.PublicPreview.Renderers;
using Iceshrimp.Backend.Controllers.Federation;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Web.Renderers;
@@ -83,7 +85,9 @@ public static class ServiceExtensions
.AddScoped()
.AddScoped()
.AddScoped()
- .AddScoped();
+ .AddScoped()
+ .AddScoped()
+ .AddScoped();
// Singleton = instantiated once across application lifetime
services
@@ -101,7 +105,10 @@ public static class ServiceExtensions
.AddSingleton()
.AddSingleton()
.AddSingleton()
- .AddSingleton();
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
var config = configuration.GetSection("Storage").Get() ??
throw new Exception("Failed to read storage config section");
diff --git a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs
index 5b68e704..bd2c2ded 100644
--- a/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs
+++ b/Iceshrimp.Backend/Core/Extensions/StringExtensions.cs
@@ -19,6 +19,12 @@ public static class StringExtensions
{
return target[..Math.Min(target.Length, maxLength)];
}
+
+ public static string TruncateEllipsis(this string target, int maxLength)
+ {
+ if (target.Length <= maxLength) return target;
+ return target[..(maxLength-3)] + "...";
+ }
private static string ToPunycode(this string target)
{
diff --git a/Iceshrimp.Backend/Core/Extensions/WebApplicationExtensions.cs b/Iceshrimp.Backend/Core/Extensions/WebApplicationExtensions.cs
index d900d701..4b6a54ed 100644
--- a/Iceshrimp.Backend/Core/Extensions/WebApplicationExtensions.cs
+++ b/Iceshrimp.Backend/Core/Extensions/WebApplicationExtensions.cs
@@ -27,7 +27,8 @@ public static class WebApplicationExtensions
.UseMiddleware()
.UseMiddleware()
.UseMiddleware()
- .UseMiddleware();
+ .UseMiddleware()
+ .UseMiddleware();
}
public static IApplicationBuilder UseSwaggerWithOptions(this WebApplication app)
diff --git a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs
index 3243cbc4..89ed7cd2 100644
--- a/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs
+++ b/Iceshrimp.Backend/Core/Helpers/LibMfm/Conversion/MfmConverter.cs
@@ -60,13 +60,13 @@ public class MfmConverter(IOptions config)
public async Task ToHtmlAsync(
IEnumerable nodes, List mentions, string? host, string? quoteUri = null,
- bool quoteInaccessible = false, bool replyInaccessible = false, bool divAsRoot = false,
+ bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List? emoji = null
)
{
var context = BrowsingContext.New();
var document = await context.OpenNewAsync();
- var element = document.CreateElement(divAsRoot ? "div" : "p");
+ var element = document.CreateElement(rootElement);
var nodeList = nodes.ToList();
var hasContent = nodeList.Count > 0;
@@ -131,13 +131,13 @@ public class MfmConverter(IOptions config)
public async Task ToHtmlAsync(
string mfm, List mentions, string? host, string? quoteUri = null,
- bool quoteInaccessible = false, bool replyInaccessible = false, bool divAsRoot = false,
+ bool quoteInaccessible = false, bool replyInaccessible = false, string rootElement = "p",
List? emoji = null
)
{
var nodes = MfmParser.Parse(mfm);
return await ToHtmlAsync(nodes, mentions, host, quoteUri, quoteInaccessible,
- replyInaccessible, divAsRoot, emoji);
+ replyInaccessible, rootElement, emoji);
}
private INode FromMfmNode(
diff --git a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs
index d7b32ef0..cf1c8a24 100644
--- a/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs
+++ b/Iceshrimp.Backend/Core/Middleware/ErrorHandlerMiddleware.cs
@@ -131,7 +131,6 @@ public class ErrorHandlerMiddleware(
};
await WriteResponse(error);
- //TODO: use the overload that takes an exception instead of printing it ourselves
logger.LogError("Request encountered an unexpected error: {exception}", e);
}
@@ -167,7 +166,7 @@ public class ErrorHandlerMiddleware(
{
var model = new ErrorPageModel(payload);
ctx.Response.ContentType = "text/html; charset=utf8";
- var stream = ctx.Response.BodyWriter.AsStream();
+ var stream = ctx.Response.Body;
await razor.RenderToStreamAsync("Shared/ErrorPage.cshtml", model, stream);
return;
}
diff --git a/Iceshrimp.Backend/Core/Middleware/StripRazorJsInitMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/StripRazorJsInitMiddleware.cs
new file mode 100644
index 00000000..fc9de131
--- /dev/null
+++ b/Iceshrimp.Backend/Core/Middleware/StripRazorJsInitMiddleware.cs
@@ -0,0 +1,50 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Components.Endpoints;
+
+namespace Iceshrimp.Backend.Core.Middleware;
+
+public class StripRazorJsInitMiddleware : IMiddleware
+{
+ private static readonly byte[] Magic = "