mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-24 19:54:22 -04:00
Add support for member-level avatars
This commit is contained in:
parent
c2c35cf3a3
commit
55209a0517
13 changed files with 133 additions and 102 deletions
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
@ -37,20 +36,16 @@ public partial record Channel
|
|||
null
|
||||
);
|
||||
|
||||
private static string GetIconUrl(Snowflake id, string iconHash)
|
||||
{
|
||||
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? "gif"
|
||||
: "png";
|
||||
|
||||
return $"https://cdn.discordapp.com/channel-icons/{id}/{iconHash}.{extension}";
|
||||
}
|
||||
|
||||
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
|
||||
public static Channel Parse(JsonElement json, ChannelCategory? categoryHint = null, int? positionHint = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
|
||||
Guild.DirectMessages.Id;
|
||||
|
||||
var category = categoryHint ?? GetFallbackCategory(kind);
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
|
@ -70,8 +65,11 @@ public partial record Channel
|
|||
positionHint ??
|
||||
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||
|
||||
// Only available on group DMs
|
||||
var iconUrl = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h));
|
||||
// Icons can only be set for group DM channels
|
||||
var iconUrl = json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
|
||||
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||
|
||||
|
@ -83,8 +81,8 @@ public partial record Channel
|
|||
return new Channel(
|
||||
id,
|
||||
kind,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetFallbackCategory(kind),
|
||||
guildId,
|
||||
category,
|
||||
name,
|
||||
position,
|
||||
iconUrl,
|
||||
|
|
|
@ -7,8 +7,6 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||
|
||||
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
||||
{
|
||||
public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "<unknown category>", 0);
|
||||
|
||||
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
|
|
60
DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs
Normal file
60
DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs
Normal file
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||
|
||||
// https://discord.com/developers/docs/reference#image-formatting
|
||||
public static class ImageCdn
|
||||
{
|
||||
// Standard emoji are rendered through Twemoji
|
||||
public static string GetStandardEmojiUrl(string emojiName)
|
||||
{
|
||||
var runes = emojiName.GetRunes().ToArray();
|
||||
|
||||
// Variant selector rune is skipped in Twemoji IDs,
|
||||
// except when the emoji also contains a zero-width joiner.
|
||||
// VS = 0xfe0f; ZWJ = 0x200d.
|
||||
var filteredRunes = runes.Any(r => r.Value == 0x200d)
|
||||
? runes
|
||||
: runes.Where(r => r.Value != 0xfe0f);
|
||||
|
||||
var twemojiId = string.Join(
|
||||
"-",
|
||||
filteredRunes.Select(r => r.Value.ToString("x"))
|
||||
);
|
||||
|
||||
return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg";
|
||||
}
|
||||
|
||||
public static string GetCustomEmojiUrl(Snowflake emojiId, bool isAnimated = false) =>
|
||||
isAnimated
|
||||
? $"https://cdn.discordapp.com/emojis/{emojiId}.gif"
|
||||
: $"https://cdn.discordapp.com/emojis/{emojiId}.png";
|
||||
|
||||
public static string GetGuildIconUrl(Snowflake guildId, string iconHash, int size = 512) =>
|
||||
iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.gif?size={size}"
|
||||
: $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.png?size={size}";
|
||||
|
||||
public static string GetChannelIconUrl(Snowflake channelId, string iconHash, int size = 512) =>
|
||||
iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.gif?size={size}"
|
||||
: $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.png?size={size}";
|
||||
|
||||
public static string GetUserAvatarUrl(Snowflake userId, string avatarHash, int size = 512) =>
|
||||
avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}"
|
||||
: $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}";
|
||||
|
||||
public static string GetFallbackUserAvatarUrl(int discriminator) =>
|
||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||
|
||||
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
|
||||
avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}"
|
||||
: $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}";
|
||||
|
||||
public static string GetStickerUrl(Snowflake stickerId, string format = "png") =>
|
||||
$"https://cdn.discordapp.com/stickers/{stickerId}.{format}";
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
@ -24,38 +24,15 @@ public partial record Emoji(
|
|||
|
||||
public partial record Emoji
|
||||
{
|
||||
private static string GetTwemojiId(string name)
|
||||
{
|
||||
var runes = name.GetRunes().ToArray();
|
||||
|
||||
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
|
||||
// VS = 0xfe0f; ZWJ = 0x200d.
|
||||
var filteredRunes = runes.Any(r => r.Value == 0x200d)
|
||||
? runes
|
||||
: runes.Where(r => r.Value != 0xfe0f);
|
||||
|
||||
return string.Join(
|
||||
"-",
|
||||
filteredRunes.Select(r => r.Value.ToString("x"))
|
||||
);
|
||||
}
|
||||
|
||||
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
|
||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||
|
||||
private static string GetImageUrl(string name) =>
|
||||
$"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{GetTwemojiId(name)}.svg";
|
||||
|
||||
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
||||
{
|
||||
// Custom emoji
|
||||
if (id is not null)
|
||||
return GetImageUrl(id.Value, isAnimated);
|
||||
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
|
||||
|
||||
// Standard emoji
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
return GetImageUrl(name);
|
||||
return ImageCdn.GetStandardEmojiUrl(name);
|
||||
|
||||
// Either ID or name should be set
|
||||
throw new ApplicationException("Emoji has neither ID nor name set.");
|
||||
|
@ -64,14 +41,16 @@ public partial record Emoji
|
|||
public static Emoji Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
||||
// Names may be missing on custom emoji within reactions
|
||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||
|
||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||
|
||||
return new Emoji(
|
||||
id,
|
||||
// Name may be missing if it's an emoji inside a reaction
|
||||
name ?? "<unknown emoji>",
|
||||
name,
|
||||
isAnimated,
|
||||
imageUrl
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
@ -9,32 +8,24 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||
{
|
||||
// Direct messages are encapsulated within a special pseudo-guild for consistency
|
||||
public static Guild DirectMessages { get; } = new(
|
||||
Snowflake.Zero,
|
||||
"Direct Messages",
|
||||
GetDefaultIconUrl()
|
||||
ImageCdn.GetFallbackUserAvatarUrl(0)
|
||||
);
|
||||
|
||||
private static string GetDefaultIconUrl() =>
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
private static string GetIconUrl(Snowflake id, string iconHash)
|
||||
{
|
||||
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? "gif"
|
||||
: "png";
|
||||
|
||||
return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}";
|
||||
}
|
||||
|
||||
public static Guild Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetNonNullString();
|
||||
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)) ??
|
||||
GetDefaultIconUrl();
|
||||
json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
|
||||
ImageCdn.GetFallbackUserAvatarUrl(0);
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||
public partial record Member(
|
||||
User User,
|
||||
string Nick,
|
||||
string? Nick,
|
||||
string? AvatarUrl,
|
||||
IReadOnlyList<Snowflake> RoleIds) : IHasId
|
||||
{
|
||||
public Snowflake Id => User.Id;
|
||||
|
@ -19,7 +20,7 @@ public partial record Member(
|
|||
|
||||
public partial record Member
|
||||
{
|
||||
public static Member Parse(JsonElement json)
|
||||
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
||||
{
|
||||
var user = json.GetProperty("user").Pipe(User.Parse);
|
||||
var nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
@ -31,9 +32,17 @@ public partial record Member
|
|||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? Array.Empty<Snowflake>();
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json
|
||||
.GetPropertyOrNull("avatar")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
|
||||
: null;
|
||||
|
||||
return new Member(
|
||||
user,
|
||||
nick ?? user.Name,
|
||||
nick,
|
||||
avatarUrl,
|
||||
roleIds
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
using System.Text.Json;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
|
@ -7,18 +9,19 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||
{
|
||||
private static string GetSourceUrl(Snowflake id, StickerFormat format)
|
||||
{
|
||||
var extension = format == StickerFormat.Lottie ? "json" : "png";
|
||||
return $"https://discord.com/stickers/{id}.{extension}";
|
||||
}
|
||||
|
||||
public static Sticker Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetNonNullString();
|
||||
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
||||
var sourceUrl = GetSourceUrl(id, format);
|
||||
|
||||
var sourceUrl = ImageCdn.GetStickerUrl(id, format switch
|
||||
{
|
||||
StickerFormat.Png => "png",
|
||||
StickerFormat.Apng => "png",
|
||||
StickerFormat.Lottie => "json",
|
||||
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
|
||||
});
|
||||
|
||||
return new Sticker(id, name, format, sourceUrl);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
public enum StickerFormat
|
||||
{
|
||||
Png = 1,
|
||||
PngAnimated = 2,
|
||||
Apng = 2,
|
||||
Lottie = 3
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
@ -21,18 +20,6 @@ public partial record User(
|
|||
|
||||
public partial record User
|
||||
{
|
||||
private static string GetDefaultAvatarUrl(int discriminator) =>
|
||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||
|
||||
private static string GetAvatarUrl(Snowflake id, string avatarHash)
|
||||
{
|
||||
var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? "gif"
|
||||
: "png";
|
||||
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
|
||||
}
|
||||
|
||||
public static User Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
|
@ -41,8 +28,11 @@ public partial record User
|
|||
var name = json.GetProperty("username").GetNonNullString();
|
||||
|
||||
var avatarUrl =
|
||||
json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetAvatarUrl(id, h)) ??
|
||||
GetDefaultAvatarUrl(discriminator);
|
||||
json
|
||||
.GetPropertyOrNull("avatar")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
|
||||
ImageCdn.GetFallbackUserAvatarUrl(discriminator);
|
||||
|
||||
return new User(id, isBot, discriminator, name, avatarUrl);
|
||||
}
|
||||
|
|
|
@ -260,7 +260,7 @@ public class DiscordClient
|
|||
return null;
|
||||
|
||||
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken);
|
||||
return response?.Pipe(Member.Parse);
|
||||
return response?.Pipe(j => Member.Parse(j, guildId));
|
||||
}
|
||||
|
||||
public async ValueTask<Invite?> TryGetGuildInviteAsync(
|
||||
|
@ -284,7 +284,7 @@ public class DiscordClient
|
|||
// Instead, we use an empty channel category as a fallback.
|
||||
catch (DiscordChatExporterException)
|
||||
{
|
||||
return ChannelCategory.Unknown;
|
||||
return new ChannelCategory(channelId, "Unknown Category", 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
|
||||
_writer.WriteString(
|
||||
"avatarUrl",
|
||||
await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken)
|
||||
await Context.ResolveAssetUrlAsync(
|
||||
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
|
|
|
@ -149,7 +149,7 @@
|
|||
}
|
||||
|
||||
// Avatar
|
||||
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(authorMember?.AvatarUrl ?? message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -172,7 +172,7 @@
|
|||
? message.ReferencedMessage.Author.Name
|
||||
: referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name;
|
||||
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(referencedUserMember?.AvatarUrl ?? message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||
<div class="chatlog__reply-content">
|
||||
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||
|
@ -205,7 +205,7 @@
|
|||
? message.Interaction.User.Name
|
||||
: interactionUserMember?.Nick ?? message.Interaction.User.Name;
|
||||
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(interactionUserMember?.AvatarUrl ?? message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserNick</div>
|
||||
<div class="chatlog__reply-content">
|
||||
used /@message.Interaction.Name
|
||||
|
@ -337,14 +337,14 @@
|
|||
<div class="chatlog__embed-invite-info">
|
||||
<div class="chatlog__embed-invite-guild-name">
|
||||
<a href="https://discord.gg/@invite.Code">
|
||||
@(invite.Guild?.Name ?? "Unknown guild")
|
||||
@(invite.Guild?.Name ?? "Unknown Guild")
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__embed-invite-channel-name">
|
||||
<svg class="chatlog__embed-invite-channel-icon">
|
||||
<use href="#channel-icon"></use>
|
||||
</svg>
|
||||
<span> @(invite.Channel?.Name ?? "Unknown channel")</span>
|
||||
<span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -621,7 +621,7 @@
|
|||
@foreach (var sticker in message.Stickers)
|
||||
{
|
||||
<div class="chatlog__sticker" title="@sticker.Name">
|
||||
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||
@if (sticker.Format is StickerFormat.Png or StickerFormat.Apng)
|
||||
{
|
||||
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue