diff --git a/OpenTween.Tests/SocialProtocol/Twitter/TwitterProfileImageUriTest.cs b/OpenTween.Tests/SocialProtocol/Twitter/TwitterProfileImageUriTest.cs
new file mode 100644
index 000000000..cfe0c049f
--- /dev/null
+++ b/OpenTween.Tests/SocialProtocol/Twitter/TwitterProfileImageUriTest.cs
@@ -0,0 +1,93 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using Xunit;
+
+namespace OpenTween.SocialProtocol.Twitter
+{
+ public class TwitterProfileImageUriTest
+ {
+ [Theory]
+ [InlineData(24, "mini")]
+ [InlineData(25, "normal")]
+ [InlineData(48, "normal")]
+ [InlineData(49, "bigger")]
+ [InlineData(73, "bigger")]
+ [InlineData(74, "original")]
+ public void SizeName_GetPreferredSize_Test(int sizePx, string expected)
+ {
+ var size = TwitterProfileImageUri.SizeName.GetPreferredSize(sizePx);
+ Assert.Equal(expected, size.Name);
+ }
+
+ [Fact]
+ public void SizeName_GetLargerOrSameSize_Test()
+ {
+ var expected = new[]
+ {
+ TwitterProfileImageUri.SizeName.Normal,
+ TwitterProfileImageUri.SizeName.Bigger,
+ TwitterProfileImageUri.SizeName.Original,
+ };
+ Assert.Equal(expected, TwitterProfileImageUri.SizeName.GetLargerOrSameSize(minSizePx: 48));
+ }
+
+ [Theory]
+ [InlineData("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg", 48, "https://pbs.twimg.com/profile_images/00000/foo_normal.jpg")]
+ [InlineData("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg", 73, "https://pbs.twimg.com/profile_images/00000/foo_bigger.jpg")]
+ [InlineData("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg", 24, "https://pbs.twimg.com/profile_images/00000/foo_mini.jpg")]
+ [InlineData("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg", 100, "https://pbs.twimg.com/profile_images/00000/foo.jpg")]
+ [InlineData("https://pbs.twimg.com/profile_images/00000/foo_normal_bar_normal.jpg", 100, "https://pbs.twimg.com/profile_images/00000/foo_normal_bar.jpg")]
+ public void GetImageUri_Test(string normalUrl, int sizePx, string expected)
+ {
+ var responsiveImageUri = new TwitterProfileImageUri(normalUrl);
+ Assert.Equal(expected, responsiveImageUri.GetImageUri(sizePx).AbsoluteUri);
+ }
+
+ [Fact]
+ public void GetImageUriLargerOrSameSize_Test()
+ {
+ var responsiveImageUri = new TwitterProfileImageUri("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg");
+ var expected = new Uri[]
+ {
+ new("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg"),
+ new("https://pbs.twimg.com/profile_images/00000/foo_bigger.jpg"),
+ new("https://pbs.twimg.com/profile_images/00000/foo.jpg"),
+ };
+ Assert.Equal(expected, responsiveImageUri.GetImageUriLargerOrSameSize(minSizePx: 48));
+ }
+
+ [Fact]
+ public void GetOriginalImageUri_Test()
+ {
+ var responsiveImageUri = new TwitterProfileImageUri("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg");
+ Assert.Equal(new("https://pbs.twimg.com/profile_images/00000/foo.jpg"), responsiveImageUri.GetOriginalImageUri());
+ }
+
+ [Fact]
+ public void GetFileName_Test()
+ {
+ var responsiveImageUri = new TwitterProfileImageUri("https://pbs.twimg.com/profile_images/00000/foo_normal.jpg");
+ Assert.Equal("foo.jpg", responsiveImageUri.GetFilename());
+ }
+ }
+}
diff --git a/OpenTween/Models/IResponsiveImageUri.cs b/OpenTween/Models/IResponsiveImageUri.cs
new file mode 100644
index 000000000..d7116da77
--- /dev/null
+++ b/OpenTween/Models/IResponsiveImageUri.cs
@@ -0,0 +1,38 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+
+namespace OpenTween.Models
+{
+ public interface IResponsiveImageUri
+ {
+ public Uri GetImageUri(int sizePx);
+
+ public Uri[] GetImageUriLargerOrSameSize(int minSizePx);
+
+ public Uri GetOriginalImageUri();
+
+ public string GetFilename();
+ }
+}
diff --git a/OpenTween/SocialProtocol/Twitter/TwitterProfileImageUri.cs b/OpenTween/SocialProtocol/Twitter/TwitterProfileImageUri.cs
new file mode 100644
index 000000000..8ec1035fe
--- /dev/null
+++ b/OpenTween/SocialProtocol/Twitter/TwitterProfileImageUri.cs
@@ -0,0 +1,101 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.IO;
+using System.Linq;
+using OpenTween.Models;
+
+namespace OpenTween.SocialProtocol.Twitter
+{
+ public record TwitterProfileImageUri(
+ string NormalImageUrlStr
+ ) : IResponsiveImageUri
+ {
+ public record SizeName
+ {
+ public string Name { get; init; }
+
+ private SizeName(string name)
+ => this.Name = name;
+
+ public static readonly SizeName Mini = new("mini");
+
+ public static readonly SizeName Normal = new("normal");
+
+ public static readonly SizeName Bigger = new("bigger");
+
+ public static readonly SizeName Original = new("original");
+
+ private static readonly (SizeName Size, int MaxSizePx)[] SizeNames = new[]
+ {
+ (Mini, 24),
+ (Normal, 48),
+ (Bigger, 73),
+ (Original, int.MaxValue),
+ };
+
+ public static SizeName GetPreferredSize(int sizePx)
+ => SizeNames.Where(x => sizePx <= x.MaxSizePx).First().Size;
+
+ public static SizeName[] GetLargerOrSameSize(int minSizePx)
+ => SizeNames.Where(x => minSizePx <= x.MaxSizePx).Select(x => x.Size).ToArray();
+ }
+
+ public Uri GetImageUri(int sizePx)
+ {
+ var sizeName = SizeName.GetPreferredSize(sizePx);
+
+ return this.GetImageUri(sizeName);
+ }
+
+ public Uri GetImageUri(SizeName size)
+ {
+ var normalUrlStr = this.NormalImageUrlStr;
+
+ // see: https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/user-profile-images-and-banners
+ string imageUrlStr;
+ if (size == SizeName.Normal)
+ imageUrlStr = normalUrlStr;
+ else if (size == SizeName.Original)
+ imageUrlStr = normalUrlStr.Replace("_normal.", ".");
+ else
+ imageUrlStr = normalUrlStr.Replace("_normal.", $"_{size.Name}.");
+
+ return new(imageUrlStr);
+ }
+
+ public Uri[] GetImageUriLargerOrSameSize(int minSizePx)
+ {
+ var sizes = SizeName.GetLargerOrSameSize(minSizePx);
+
+ return sizes.Select(x => this.GetImageUri(x)).ToArray();
+ }
+
+ public Uri GetOriginalImageUri()
+ => this.GetImageUri(SizeName.Original);
+
+ public string GetFilename()
+ => Path.GetFileName(this.GetOriginalImageUri().AbsolutePath);
+ }
+}