OSDN Git Service

PostClassインスタンスの生成処理をTwitterPostFactoryクラスに分離
authorKimura Youichi <kim.upsilon@bucyou.net>
Sat, 28 May 2022 22:04:58 +0000 (07:04 +0900)
committerKimura Youichi <kim.upsilon@bucyou.net>
Thu, 7 Jul 2022 20:24:20 +0000 (05:24 +0900)
OpenTween.Tests/Models/PostClassTest.cs
OpenTween.Tests/Models/TwitterPostFactoryTest.cs [new file with mode: 0644]
OpenTween.Tests/TwitterTest.cs
OpenTween/Models/PostClass.cs
OpenTween/Models/TabInformations.cs
OpenTween/Models/TwitterPostFactory.cs [new file with mode: 0644]
OpenTween/OpenTween.csproj
OpenTween/Twitter.cs

index e75575f..eebcf63 100644 (file)
@@ -417,6 +417,8 @@ namespace OpenTween.Models
         [Fact]
         public async Task ExpandedUrls_BasicScenario()
         {
+            PostClass.ExpandedUrlInfo.AutoExpand = true;
+
             var post = new PostClass
             {
                 Text = "<a href=\"http://t.co/aaaaaaa\" title=\"http://t.co/aaaaaaa\">bit.ly/abcde</a>",
diff --git a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs
new file mode 100644 (file)
index 0000000..a62d8a3
--- /dev/null
@@ -0,0 +1,643 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2013 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System;
+using System.Collections.Generic;
+using OpenTween.Api.DataModel;
+using Xunit;
+
+namespace OpenTween.Models
+{
+    public class TwitterPostFactoryTest
+    {
+        private static readonly ISet<long> EmptyIdSet = new HashSet<long>();
+
+        private readonly Random random = new();
+
+        public TwitterPostFactoryTest()
+            => PostClass.ExpandedUrlInfo.AutoExpand = false;
+
+        private TabInformations CreateTabinfo()
+        {
+            var tabinfo = new TabInformations();
+            tabinfo.AddDefaultTabs();
+            return tabinfo;
+        }
+
+        private TwitterStatus CreateStatus()
+        {
+            var statusId = this.random.Next(10000);
+
+            return new()
+            {
+                Id = statusId,
+                IdStr = statusId.ToString(),
+                CreatedAt = "Sat Jan 01 00:00:00 +0000 2022",
+                FullText = "hoge",
+                Source = "<a href=\"https://www.opentween.org/\" rel=\"nofollow\">OpenTween</a>",
+                Entities = new(),
+                User = this.CreateUser(),
+            };
+        }
+
+        private TwitterUser CreateUser()
+        {
+            var userId = this.random.Next(10000);
+
+            return new()
+            {
+                Id = userId,
+                IdStr = userId.ToString(),
+                ScreenName = "tetete",
+                Name = "ててて",
+                ProfileImageUrlHttps = "https://example.com/profile.png",
+            };
+        }
+
+        [Fact]
+        public void CreateFromStatus_Test()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+            var status = this.CreateStatus();
+            var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds: EmptyIdSet);
+
+            Assert.Equal(status.Id, post.StatusId);
+            Assert.Equal(new DateTimeUtc(2022, 1, 1, 0, 0, 0), post.CreatedAt);
+            Assert.Equal("hoge", post.Text);
+            Assert.Equal("hoge", post.TextFromApi);
+            Assert.Equal("hoge", post.TextSingleLine);
+            Assert.Equal("hoge", post.AccessibleText);
+            Assert.Empty(post.ReplyToList);
+            Assert.Empty(post.QuoteStatusIds);
+            Assert.Empty(post.ExpandedUrls);
+            Assert.Empty(post.Media);
+            Assert.Null(post.PostGeo);
+            Assert.Equal("OpenTween", post.Source);
+            Assert.Equal("https://www.opentween.org/", post.SourceUri?.OriginalString);
+            Assert.Equal(0, post.FavoritedCount);
+            Assert.False(post.IsFav);
+            Assert.False(post.IsDm);
+            Assert.False(post.IsDeleted);
+            Assert.False(post.IsRead);
+            Assert.False(post.IsExcludeReply);
+            Assert.False(post.FilterHit);
+            Assert.False(post.IsMark);
+
+            Assert.False(post.IsReply);
+            Assert.Null(post.InReplyToStatusId);
+            Assert.Null(post.InReplyToUserId);
+            Assert.Null(post.InReplyToUser);
+
+            Assert.Null(post.RetweetedId);
+            Assert.Null(post.RetweetedBy);
+            Assert.Null(post.RetweetedByUserId);
+
+            Assert.Equal(status.User.Id, post.UserId);
+            Assert.Equal("tetete", post.ScreenName);
+            Assert.Equal("ててて", post.Nickname);
+            Assert.Equal("https://example.com/profile.png", post.ImageUrl);
+            Assert.False(post.IsProtect);
+            Assert.False(post.IsOwl);
+            Assert.False(post.IsMe);
+        }
+
+        [Fact]
+        public void CreateFromStatus_AuthorTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+            var status = this.CreateStatus();
+            var selfUserId = status.User.Id;
+            var post = factory.CreateFromStatus(status, selfUserId, followerIds: EmptyIdSet);
+
+            Assert.True(post.IsMe);
+        }
+
+        [Fact]
+        public void CreateFromStatus_FollowerTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+            var status = this.CreateStatus();
+            var followerIds = new HashSet<long> { status.User.Id };
+            var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds);
+
+            Assert.False(post.IsOwl);
+        }
+
+        [Fact]
+        public void CreateFromStatus_NotFollowerTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+            var status = this.CreateStatus();
+            var followerIds = new HashSet<long> { 30000L };
+            var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds);
+
+            Assert.True(post.IsOwl);
+        }
+
+        [Fact]
+        public void CreateFromStatus_RetweetTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+            var originalStatus = this.CreateStatus();
+
+            var retweetStatus = this.CreateStatus();
+            retweetStatus.RetweetedStatus = originalStatus;
+            retweetStatus.Source = "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>";
+
+            var post = factory.CreateFromStatus(retweetStatus, selfUserId: 20000L, followerIds: EmptyIdSet);
+
+            Assert.Equal(retweetStatus.Id, post.StatusId);
+            Assert.Equal(retweetStatus.User.Id, post.RetweetedByUserId);
+            Assert.Equal(originalStatus.Id, post.RetweetedId);
+            Assert.Equal(originalStatus.User.Id, post.UserId);
+
+            Assert.Equal("OpenTween", post.Source);
+            Assert.Equal("https://www.opentween.org/", post.SourceUri?.OriginalString);
+        }
+
+        private TwitterMessageEvent CreateDirectMessage(string senderId, string recipientId)
+        {
+            var messageId = this.random.Next(10000);
+
+            return new()
+            {
+                Type = "message_create",
+                Id = messageId.ToString(),
+                CreatedTimestamp = "1640995200000",
+                MessageCreate = new()
+                {
+                    SenderId = senderId,
+                    Target = new()
+                    {
+                        RecipientId = recipientId,
+                    },
+                    MessageData = new()
+                    {
+                        Text = "hoge",
+                        Entities = new(),
+                    },
+                    SourceAppId = "22519141",
+                },
+            };
+        }
+
+        private Dictionary<string, TwitterMessageEventList.App> CreateApps()
+        {
+            return new()
+            {
+                ["22519141"] = new()
+                {
+                    Id = "22519141",
+                    Name = "OpenTween",
+                    Url = "https://www.opentween.org/",
+                },
+            };
+        }
+
+        [Fact]
+        public void CreateFromDirectMessageEvent_Test()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var selfUser = this.CreateUser();
+            var otherUser = this.CreateUser();
+            var eventItem = this.CreateDirectMessage(senderId: otherUser.IdStr, recipientId: selfUser.IdStr);
+            var users = new Dictionary<string, TwitterUser>()
+            {
+                [selfUser.IdStr] = selfUser,
+                [otherUser.IdStr] = otherUser,
+            };
+            var apps = this.CreateApps();
+            var post = factory.CreateFromDirectMessageEvent(eventItem, users, apps, selfUserId: selfUser.Id);
+
+            Assert.Equal(long.Parse(eventItem.Id), post.StatusId);
+            Assert.Equal(new DateTimeUtc(2022, 1, 1, 0, 0, 0), post.CreatedAt);
+            Assert.Equal("hoge", post.Text);
+            Assert.Equal("hoge", post.TextFromApi);
+            Assert.Equal("hoge", post.TextSingleLine);
+            Assert.Equal("hoge", post.AccessibleText);
+            Assert.Empty(post.ReplyToList);
+            Assert.Empty(post.QuoteStatusIds);
+            Assert.Empty(post.ExpandedUrls);
+            Assert.Empty(post.Media);
+            Assert.Null(post.PostGeo);
+            Assert.Equal("OpenTween", post.Source);
+            Assert.Equal("https://www.opentween.org/", post.SourceUri?.OriginalString);
+            Assert.Equal(0, post.FavoritedCount);
+            Assert.False(post.IsFav);
+            Assert.True(post.IsDm);
+            Assert.False(post.IsDeleted);
+            Assert.False(post.IsRead);
+            Assert.False(post.IsExcludeReply);
+            Assert.False(post.FilterHit);
+            Assert.False(post.IsMark);
+
+            Assert.False(post.IsReply);
+            Assert.Null(post.InReplyToStatusId);
+            Assert.Null(post.InReplyToUserId);
+            Assert.Null(post.InReplyToUser);
+
+            Assert.Null(post.RetweetedId);
+            Assert.Null(post.RetweetedBy);
+            Assert.Null(post.RetweetedByUserId);
+
+            Assert.Equal(otherUser.Id, post.UserId);
+            Assert.Equal("tetete", post.ScreenName);
+            Assert.Equal("ててて", post.Nickname);
+            Assert.Equal("https://example.com/profile.png", post.ImageUrl);
+            Assert.False(post.IsProtect);
+            Assert.True(post.IsOwl);
+            Assert.False(post.IsMe);
+        }
+
+        [Fact]
+        public void CreateFromDirectMessageEvent_SenderTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var selfUser = this.CreateUser();
+            var otherUser = this.CreateUser();
+            var eventItem = this.CreateDirectMessage(senderId: selfUser.IdStr, recipientId: otherUser.IdStr);
+            var users = new Dictionary<string, TwitterUser>()
+            {
+                [selfUser.IdStr] = selfUser,
+                [otherUser.IdStr] = otherUser,
+            };
+            var apps = this.CreateApps();
+            var post = factory.CreateFromDirectMessageEvent(eventItem, users, apps, selfUserId: selfUser.Id);
+
+            Assert.Equal(otherUser.Id, post.UserId);
+            Assert.False(post.IsOwl);
+            Assert.True(post.IsMe);
+        }
+
+        [Fact]
+        public void CreateFromStatus_MediaAltTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var status = this.CreateStatus();
+            status.FullText = "https://t.co/hoge";
+            status.ExtendedEntities = new()
+            {
+                Media = new[]
+                {
+                    new TwitterEntityMedia
+                    {
+                        Indices = new[] { 0, 17 },
+                        Url = "https://t.co/hoge",
+                        DisplayUrl = "pic.twitter.com/hoge",
+                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890/photo/1",
+                        AltText = "代替テキスト",
+                    },
+                },
+            };
+
+            var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet);
+
+            var accessibleText = string.Format(Properties.Resources.ImageAltText, "代替テキスト");
+            Assert.Equal(accessibleText, post.AccessibleText);
+            Assert.Equal("<a href=\"https://t.co/hoge\" title=\"代替テキスト\">pic.twitter.com/hoge</a>", post.Text);
+            Assert.Equal("pic.twitter.com/hoge", post.TextFromApi);
+            Assert.Equal("pic.twitter.com/hoge", post.TextSingleLine);
+        }
+
+        [Fact]
+        public void CreateFromStatus_MediaNoAltTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var status = this.CreateStatus();
+            status.FullText = "https://t.co/hoge";
+            status.ExtendedEntities = new()
+            {
+                Media = new[]
+                {
+                    new TwitterEntityMedia
+                    {
+                        Indices = new[] { 0, 17 },
+                        Url = "https://t.co/hoge",
+                        DisplayUrl = "pic.twitter.com/hoge",
+                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890/photo/1",
+                        AltText = null,
+                    },
+                },
+            };
+
+            var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet);
+
+            Assert.Equal("pic.twitter.com/hoge", post.AccessibleText);
+            Assert.Equal("<a href=\"https://t.co/hoge\" title=\"https://twitter.com/hoge/status/1234567890/photo/1\">pic.twitter.com/hoge</a>", post.Text);
+            Assert.Equal("pic.twitter.com/hoge", post.TextFromApi);
+            Assert.Equal("pic.twitter.com/hoge", post.TextSingleLine);
+        }
+
+        [Fact]
+        public void CreateFromStatus_QuotedUrlTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var status = this.CreateStatus();
+            status.FullText = "https://t.co/hoge";
+            status.Entities = new()
+            {
+                Urls = new[]
+                {
+                    new TwitterEntityUrl
+                    {
+                        Indices = new[] { 0, 17 },
+                        Url = "https://t.co/hoge",
+                        DisplayUrl = "twitter.com/hoge/status/1…",
+                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890",
+                    },
+                },
+            };
+            status.QuotedStatus = new()
+            {
+                Id = 1234567890L,
+                IdStr = "1234567890",
+                User = new()
+                {
+                    Id = 1111,
+                    IdStr = "1111",
+                    ScreenName = "foo",
+                },
+                FullText = "test",
+            };
+
+            var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet);
+
+            var accessibleText = string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test");
+            Assert.Equal(accessibleText, post.AccessibleText);
+            Assert.Equal("<a href=\"https://twitter.com/hoge/status/1234567890\" title=\"https://twitter.com/hoge/status/1234567890\">twitter.com/hoge/status/1…</a>", post.Text);
+            Assert.Equal("twitter.com/hoge/status/1…", post.TextFromApi);
+            Assert.Equal("twitter.com/hoge/status/1…", post.TextSingleLine);
+        }
+
+        [Fact]
+        public void CreateFromStatus_QuotedUrlWithPermelinkTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var status = this.CreateStatus();
+            status.FullText = "hoge";
+            status.QuotedStatus = new()
+            {
+                Id = 1234567890L,
+                IdStr = "1234567890",
+                User = new TwitterUser
+                {
+                    Id = 1111,
+                    IdStr = "1111",
+                    ScreenName = "foo",
+                },
+                FullText = "test",
+            };
+            status.QuotedStatusPermalink = new()
+            {
+                Url = "https://t.co/hoge",
+                Display = "twitter.com/hoge/status/1…",
+                Expanded = "https://twitter.com/hoge/status/1234567890",
+            };
+
+            var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet);
+
+            var accessibleText = "hoge " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test");
+            Assert.Equal(accessibleText, post.AccessibleText);
+            Assert.Equal("hoge <a href=\"https://twitter.com/hoge/status/1234567890\" title=\"https://twitter.com/hoge/status/1234567890\">twitter.com/hoge/status/1…</a>", post.Text);
+            Assert.Equal("hoge twitter.com/hoge/status/1…", post.TextFromApi);
+            Assert.Equal("hoge twitter.com/hoge/status/1…", post.TextSingleLine);
+        }
+
+        [Fact]
+        public void CreateFromStatus_QuotedUrlNoReferenceTest()
+        {
+            var factory = new TwitterPostFactory(this.CreateTabinfo());
+
+            var status = this.CreateStatus();
+            status.FullText = "https://t.co/hoge";
+            status.Entities = new()
+            {
+                Urls = new[]
+                {
+                    new TwitterEntityUrl
+                    {
+                        Indices = new[] { 0, 17 },
+                        Url = "https://t.co/hoge",
+                        DisplayUrl = "twitter.com/hoge/status/1…",
+                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890",
+                    },
+                },
+            };
+            status.QuotedStatus = null;
+
+            var post = factory.CreateFromStatus(status, selfUserId: 100L, followerIds: EmptyIdSet);
+
+            var accessibleText = "twitter.com/hoge/status/1…";
+            Assert.Equal(accessibleText, post.AccessibleText);
+            Assert.Equal("<a href=\"https://twitter.com/hoge/status/1234567890\" title=\"https://twitter.com/hoge/status/1234567890\">twitter.com/hoge/status/1…</a>", post.Text);
+            Assert.Equal("twitter.com/hoge/status/1…", post.TextFromApi);
+            Assert.Equal("twitter.com/hoge/status/1…", post.TextSingleLine);
+        }
+
+        [Fact]
+        public void CreateHtmlAnchor_Test()
+        {
+            var text = "@twitterapi #BreakingMyTwitter https://t.co/mIJcSoVSK3";
+            var entities = new TwitterEntities
+            {
+                UserMentions = new[]
+                {
+                    new TwitterEntityMention { Indices = new[] { 0, 11 }, ScreenName = "twitterapi" },
+                },
+                Hashtags = new[]
+                {
+                    new TwitterEntityHashtag { Indices = new[] { 12, 30 }, Text = "BreakingMyTwitter" },
+                },
+                Urls = new[]
+                {
+                    new TwitterEntityUrl
+                    {
+                        Indices = new[] { 31, 54 },
+                        Url = "https://t.co/mIJcSoVSK3",
+                        DisplayUrl = "apps-of-a-feather.com",
+                        ExpandedUrl = "http://apps-of-a-feather.com/",
+                    },
+                },
+            };
+
+            var expectedHtml = @"<a class=""mention"" href=""https://twitter.com/twitterapi"">@twitterapi</a>"
+                + @" <a class=""hashtag"" href=""https://twitter.com/search?q=%23BreakingMyTwitter"">#BreakingMyTwitter</a>"
+                + @" <a href=""https://t.co/mIJcSoVSK3"" title=""https://t.co/mIJcSoVSK3"">apps-of-a-feather.com</a>";
+
+            Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink: null));
+        }
+
+        [Fact]
+        public void CreateHtmlAnchor_NicovideoTest()
+        {
+            var text = "sm9";
+            var entities = new TwitterEntities();
+
+            var expectedHtml = @"<a href=""https://www.nicovideo.jp/watch/sm9"">sm9</a>";
+
+            Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink: null));
+        }
+
+        [Fact]
+        public void CreateHtmlAnchor_QuotedUrlWithPermelinkTest()
+        {
+            var text = "hoge";
+            var entities = new TwitterEntities();
+            var quotedStatusLink = new TwitterQuotedStatusPermalink
+            {
+                Url = "https://t.co/hoge",
+                Display = "twitter.com/hoge/status/1…",
+                Expanded = "https://twitter.com/hoge/status/1234567890",
+            };
+
+            var expectedHtml = @"hoge"
+                + @" <a href=""https://twitter.com/hoge/status/1234567890"" title=""https://twitter.com/hoge/status/1234567890"">twitter.com/hoge/status/1…</a>";
+
+            Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink));
+        }
+
+        [Fact]
+        public void ParseSource_Test()
+        {
+            var sourceHtml = "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>";
+
+            var expected = ("Twitter Web Client", new Uri("http://twitter.com/"));
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_PlainTextTest()
+        {
+            var sourceHtml = "web";
+
+            var expected = ("web", (Uri?)null);
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_RelativeUriTest()
+        {
+            // 参照: https://twitter.com/kim_upsilon/status/477796052049752064
+            var sourceHtml = "<a href=\"erased_45416\" rel=\"nofollow\">erased_45416</a>";
+
+            var expected = ("erased_45416", new Uri("https://twitter.com/erased_45416"));
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_EmptyTest()
+        {
+            // 参照: https://twitter.com/kim_upsilon/status/595156014032244738
+            var sourceHtml = "";
+
+            var expected = ("", (Uri?)null);
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_NullTest()
+        {
+            string? sourceHtml = null;
+
+            var expected = ("", (Uri?)null);
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_UnescapeTest()
+        {
+            var sourceHtml = "<a href=\"http://example.com/?aaa=123&amp;bbb=456\" rel=\"nofollow\">&lt;&lt;hogehoge&gt;&gt;</a>";
+
+            var expected = ("<<hogehoge>>", new Uri("http://example.com/?aaa=123&bbb=456"));
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void ParseSource_UnescapeNoUriTest()
+        {
+            var sourceHtml = "&lt;&lt;hogehoge&gt;&gt;";
+
+            var expected = ("<<hogehoge>>", (Uri?)null);
+            Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml));
+        }
+
+        [Fact]
+        public void GetQuoteTweetStatusIds_EntityTest()
+        {
+            var entities = new[]
+            {
+                new TwitterEntityUrl
+                {
+                    Url = "https://t.co/3HXq0LrbJb",
+                    ExpandedUrl = "https://twitter.com/kim_upsilon/status/599261132361072640",
+                },
+            };
+
+            var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(entities, quotedStatusLink: null);
+            Assert.Equal(new[] { 599261132361072640L }, statusIds);
+        }
+
+        [Fact]
+        public void GetQuoteTweetStatusIds_QuotedStatusLinkTest()
+        {
+            var entities = new TwitterEntities();
+            var quotedStatusLink = new TwitterQuotedStatusPermalink
+            {
+                Url = "https://t.co/3HXq0LrbJb",
+                Expanded = "https://twitter.com/kim_upsilon/status/599261132361072640",
+            };
+
+            var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(entities, quotedStatusLink);
+            Assert.Equal(new[] { 599261132361072640L }, statusIds);
+        }
+
+        [Fact]
+        public void GetQuoteTweetStatusIds_UrlStringTest()
+        {
+            var urls = new[]
+            {
+                "https://twitter.com/kim_upsilon/status/599261132361072640",
+            };
+
+            var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(urls);
+            Assert.Equal(new[] { 599261132361072640L }, statusIds);
+        }
+
+        [Fact]
+        public void GetQuoteTweetStatusIds_OverflowTest()
+        {
+            var urls = new[]
+            {
+                // 符号付き 64 ビット整数の範囲を超える値
+                "https://twitter.com/kim_upsilon/status/9999999999999999999",
+            };
+
+            var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(urls);
+            Assert.Empty(statusIds);
+        }
+    }
+}
index 7da053a..6794f17 100644 (file)
@@ -109,324 +109,6 @@ namespace OpenTween
         }
 
         [Fact]
-        public void CreateAccessibleText_MediaAltTest()
-        {
-            var text = "https://t.co/hoge";
-            var entities = new TwitterEntities
-            {
-                Media = new[]
-                {
-                    new TwitterEntityMedia
-                    {
-                        Indices = new[] { 0, 17 },
-                        Url = "https://t.co/hoge",
-                        DisplayUrl = "pic.twitter.com/hoge",
-                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890/photo/1",
-                        AltText = "代替テキスト",
-                    },
-                },
-            };
-
-            var expectedText = string.Format(Properties.Resources.ImageAltText, "代替テキスト");
-
-            Assert.Equal(expectedText, Twitter.CreateAccessibleText(text, entities, quotedStatus: null, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateAccessibleText_MediaNoAltTest()
-        {
-            var text = "https://t.co/hoge";
-            var entities = new TwitterEntities
-            {
-                Media = new[]
-                {
-                    new TwitterEntityMedia
-                    {
-                        Indices = new[] { 0, 17 },
-                        Url = "https://t.co/hoge",
-                        DisplayUrl = "pic.twitter.com/hoge",
-                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890/photo/1",
-                        AltText = null,
-                    },
-                },
-            };
-
-            var expectedText = "pic.twitter.com/hoge";
-
-            Assert.Equal(expectedText, Twitter.CreateAccessibleText(text, entities, quotedStatus: null, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateAccessibleText_QuotedUrlTest()
-        {
-            var text = "https://t.co/hoge";
-            var entities = new TwitterEntities
-            {
-                Urls = new[]
-                {
-                    new TwitterEntityUrl
-                    {
-                        Indices = new[] { 0, 17 },
-                        Url = "https://t.co/hoge",
-                        DisplayUrl = "twitter.com/hoge/status/1…",
-                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890",
-                    },
-                },
-            };
-            var quotedStatus = new TwitterStatus
-            {
-                Id = 1234567890L,
-                IdStr = "1234567890",
-                User = new TwitterUser
-                {
-                    Id = 1111,
-                    IdStr = "1111",
-                    ScreenName = "foo",
-                },
-                FullText = "test",
-            };
-
-            var expectedText = string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test");
-
-            Assert.Equal(expectedText, Twitter.CreateAccessibleText(text, entities, quotedStatus, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateAccessibleText_QuotedUrlWithPermelinkTest()
-        {
-            var text = "hoge";
-            var entities = new TwitterEntities();
-            var quotedStatus = new TwitterStatus
-            {
-                Id = 1234567890L,
-                IdStr = "1234567890",
-                User = new TwitterUser
-                {
-                    Id = 1111,
-                    IdStr = "1111",
-                    ScreenName = "foo",
-                },
-                FullText = "test",
-            };
-            var quotedStatusLink = new TwitterQuotedStatusPermalink
-            {
-                Url = "https://t.co/hoge",
-                Display = "twitter.com/hoge/status/1…",
-                Expanded = "https://twitter.com/hoge/status/1234567890",
-            };
-
-            var expectedText = "hoge " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, "foo", "test");
-
-            Assert.Equal(expectedText, Twitter.CreateAccessibleText(text, entities, quotedStatus, quotedStatusLink));
-        }
-
-        [Fact]
-        public void CreateAccessibleText_QuotedUrlNoReferenceTest()
-        {
-            var text = "https://t.co/hoge";
-            var entities = new TwitterEntities
-            {
-                Urls = new[]
-                {
-                    new TwitterEntityUrl
-                    {
-                        Indices = new[] { 0, 17 },
-                        Url = "https://t.co/hoge",
-                        DisplayUrl = "twitter.com/hoge/status/1…",
-                        ExpandedUrl = "https://twitter.com/hoge/status/1234567890",
-                    },
-                },
-            };
-            var quotedStatus = (TwitterStatus?)null;
-
-            var expectedText = "twitter.com/hoge/status/1…";
-
-            Assert.Equal(expectedText, Twitter.CreateAccessibleText(text, entities, quotedStatus, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateHtmlAnchor_Test()
-        {
-            var text = "@twitterapi #BreakingMyTwitter https://t.co/mIJcSoVSK3";
-            var entities = new TwitterEntities
-            {
-                UserMentions = new[]
-                {
-                    new TwitterEntityMention { Indices = new[] { 0, 11 }, ScreenName = "twitterapi" },
-                },
-                Hashtags = new[]
-                {
-                    new TwitterEntityHashtag { Indices = new[] { 12, 30 }, Text = "BreakingMyTwitter" },
-                },
-                Urls = new[]
-                {
-                    new TwitterEntityUrl
-                    {
-                        Indices = new[] { 31, 54 },
-                        Url = "https://t.co/mIJcSoVSK3",
-                        DisplayUrl = "apps-of-a-feather.com",
-                        ExpandedUrl = "http://apps-of-a-feather.com/",
-                    },
-                },
-            };
-
-            var expectedHtml = @"<a class=""mention"" href=""https://twitter.com/twitterapi"">@twitterapi</a>"
-                + @" <a class=""hashtag"" href=""https://twitter.com/search?q=%23BreakingMyTwitter"">#BreakingMyTwitter</a>"
-                + @" <a href=""https://t.co/mIJcSoVSK3"" title=""https://t.co/mIJcSoVSK3"">apps-of-a-feather.com</a>";
-
-            Assert.Equal(expectedHtml, Twitter.CreateHtmlAnchor(text, entities, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateHtmlAnchor_NicovideoTest()
-        {
-            var text = "sm9";
-            var entities = new TwitterEntities();
-
-            var expectedHtml = @"<a href=""https://www.nicovideo.jp/watch/sm9"">sm9</a>";
-
-            Assert.Equal(expectedHtml, Twitter.CreateHtmlAnchor(text, entities, quotedStatusLink: null));
-        }
-
-        [Fact]
-        public void CreateHtmlAnchor_QuotedUrlWithPermelinkTest()
-        {
-            var text = "hoge";
-            var entities = new TwitterEntities();
-            var quotedStatusLink = new TwitterQuotedStatusPermalink
-            {
-                Url = "https://t.co/hoge",
-                Display = "twitter.com/hoge/status/1…",
-                Expanded = "https://twitter.com/hoge/status/1234567890",
-            };
-
-            var expectedHtml = @"hoge"
-                + @" <a href=""https://t.co/hoge"" title=""https://t.co/hoge"">twitter.com/hoge/status/1…</a>";
-
-            Assert.Equal(expectedHtml, Twitter.CreateHtmlAnchor(text, entities, quotedStatusLink));
-        }
-
-        [Fact]
-        public void ParseSource_Test()
-        {
-            var sourceHtml = "<a href=\"http://twitter.com\" rel=\"nofollow\">Twitter Web Client</a>";
-
-            var expected = ("Twitter Web Client", new Uri("http://twitter.com/"));
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_PlainTextTest()
-        {
-            var sourceHtml = "web";
-
-            var expected = ("web", (Uri?)null);
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_RelativeUriTest()
-        {
-            // 参照: https://twitter.com/kim_upsilon/status/477796052049752064
-            var sourceHtml = "<a href=\"erased_45416\" rel=\"nofollow\">erased_45416</a>";
-
-            var expected = ("erased_45416", new Uri("https://twitter.com/erased_45416"));
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_EmptyTest()
-        {
-            // 参照: https://twitter.com/kim_upsilon/status/595156014032244738
-            var sourceHtml = "";
-
-            var expected = ("", (Uri?)null);
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_NullTest()
-        {
-            string? sourceHtml = null;
-
-            var expected = ("", (Uri?)null);
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_UnescapeTest()
-        {
-            var sourceHtml = "<a href=\"http://example.com/?aaa=123&amp;bbb=456\" rel=\"nofollow\">&lt;&lt;hogehoge&gt;&gt;</a>";
-
-            var expected = ("<<hogehoge>>", new Uri("http://example.com/?aaa=123&bbb=456"));
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void ParseSource_UnescapeNoUriTest()
-        {
-            var sourceHtml = "&lt;&lt;hogehoge&gt;&gt;";
-
-            var expected = ("<<hogehoge>>", (Uri?)null);
-            Assert.Equal(expected, Twitter.ParseSource(sourceHtml));
-        }
-
-        [Fact]
-        public void GetQuoteTweetStatusIds_EntityTest()
-        {
-            var entities = new[]
-            {
-                new TwitterEntityUrl
-                {
-                    Url = "https://t.co/3HXq0LrbJb",
-                    ExpandedUrl = "https://twitter.com/kim_upsilon/status/599261132361072640",
-                },
-            };
-
-            var statusIds = Twitter.GetQuoteTweetStatusIds(entities, quotedStatusLink: null);
-            Assert.Equal(new[] { 599261132361072640L }, statusIds);
-        }
-
-        [Fact]
-        public void GetQuoteTweetStatusIds_QuotedStatusLinkTest()
-        {
-            var entities = new TwitterEntities();
-            var quotedStatusLink = new TwitterQuotedStatusPermalink
-            {
-                Url = "https://t.co/3HXq0LrbJb",
-                Expanded = "https://twitter.com/kim_upsilon/status/599261132361072640",
-            };
-
-            var statusIds = Twitter.GetQuoteTweetStatusIds(entities, quotedStatusLink);
-            Assert.Equal(new[] { 599261132361072640L }, statusIds);
-        }
-
-        [Fact]
-        public void GetQuoteTweetStatusIds_UrlStringTest()
-        {
-            var urls = new[]
-            {
-                "https://twitter.com/kim_upsilon/status/599261132361072640",
-            };
-
-            var statusIds = Twitter.GetQuoteTweetStatusIds(urls);
-            Assert.Equal(new[] { 599261132361072640L }, statusIds);
-        }
-
-        [Fact]
-        public void GetQuoteTweetStatusIds_OverflowTest()
-        {
-            var urls = new[]
-            {
-                // 符号付き 64 ビット整数の範囲を超える値
-                "https://twitter.com/kim_upsilon/status/9999999999999999999",
-            };
-
-            var statusIds = Twitter.GetQuoteTweetStatusIds(urls);
-            Assert.Empty(statusIds);
-        }
-
-        [Fact]
         public void GetApiResultCount_DefaultTest()
         {
             var oldInstance = SettingManagerTest.Common;
index b91ab0a..e52e333 100644 (file)
@@ -134,6 +134,8 @@ namespace OpenTween.Models
         /// </summary>
         public class ExpandedUrlInfo : ICloneable
         {
+            public static bool AutoExpand { get; set; } = true;
+
             /// <summary>展開前の t.co ドメインの URL</summary>
             public string Url { get; }
 
@@ -161,7 +163,7 @@ namespace OpenTween.Models
                 this.Url = url;
                 this.expandedUrl = expandedUrl;
 
-                if (deepExpand)
+                if (AutoExpand && deepExpand)
                     this.ExpandTask = this.DeepExpandAsync();
                 else
                     this.ExpandTask = Task.CompletedTask;
index a9769d3..1eac80f 100644 (file)
@@ -82,7 +82,7 @@ namespace OpenTween.Models
         // List
         private List<ListElement> lists = new();
 
-        private TabInformations()
+        internal TabInformations()
         {
         }
 
diff --git a/OpenTween/Models/TwitterPostFactory.cs b/OpenTween/Models/TwitterPostFactory.cs
new file mode 100644 (file)
index 0000000..b792b4b
--- /dev/null
@@ -0,0 +1,570 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2022 kim_upsilon (@kim_upsilon) <https://upsilo.net/~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 <http://www.gnu.org/licenses/>, or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+using OpenTween.Api.DataModel;
+
+namespace OpenTween.Models
+{
+    public class TwitterPostFactory
+    {
+        private static readonly Uri SourceUriBase = new("https://twitter.com/");
+
+        private readonly TabInformations tabinfo;
+        private readonly HashSet<string> receivedHashTags = new();
+
+        public TwitterPostFactory(TabInformations tabinfo)
+            => this.tabinfo = tabinfo;
+
+        public string[] GetReceivedHashtags()
+        {
+            lock (this.receivedHashTags)
+            {
+                var hashtags = this.receivedHashTags.ToArray();
+                this.receivedHashTags.Clear();
+                return hashtags;
+            }
+        }
+
+        public PostClass CreateFromStatus(
+            TwitterStatus status,
+            long selfUserId,
+            ISet<long> followerIds,
+            bool favTweet = false
+        )
+        {
+            var post = new PostClass();
+            TwitterEntities entities;
+            string sourceHtml;
+
+            post.StatusId = status.Id;
+            if (status.RetweetedStatus != null)
+            {
+                var retweeted = status.RetweetedStatus;
+
+                post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
+
+                // Id
+                post.RetweetedId = retweeted.Id;
+                // 本文
+                post.TextFromApi = retweeted.FullText;
+                entities = retweeted.MergedEntities;
+                sourceHtml = retweeted.Source;
+                // Reply先
+                post.InReplyToStatusId = retweeted.InReplyToStatusId;
+                post.InReplyToUser = retweeted.InReplyToScreenName;
+                post.InReplyToUserId = status.InReplyToUserId;
+
+                if (favTweet)
+                {
+                    post.IsFav = true;
+                }
+                else
+                {
+                    // 幻覚fav対策
+                    var favTab = this.tabinfo.FavoriteTab;
+                    post.IsFav = favTab.Contains(retweeted.Id);
+                }
+
+                if (retweeted.Coordinates != null)
+                    post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
+
+                // 以下、ユーザー情報
+                var user = retweeted.User;
+                if (user != null)
+                {
+                    post.UserId = user.Id;
+                    post.ScreenName = user.ScreenName;
+                    post.Nickname = user.Name.Trim();
+                    post.ImageUrl = user.ProfileImageUrlHttps;
+                    post.IsProtect = user.Protected;
+                }
+                else
+                {
+                    post.UserId = 0L;
+                    post.ScreenName = "?????";
+                    post.Nickname = "Unknown User";
+                }
+
+                // Retweetした人
+                if (status.User != null)
+                {
+                    post.RetweetedBy = status.User.ScreenName;
+                    post.RetweetedByUserId = status.User.Id;
+                    post.IsMe = post.RetweetedByUserId == selfUserId;
+                }
+                else
+                {
+                    post.RetweetedBy = "?????";
+                    post.RetweetedByUserId = 0L;
+                }
+            }
+            else
+            {
+                post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
+                // 本文
+                post.TextFromApi = status.FullText;
+                entities = status.MergedEntities;
+                sourceHtml = status.Source;
+                post.InReplyToStatusId = status.InReplyToStatusId;
+                post.InReplyToUser = status.InReplyToScreenName;
+                post.InReplyToUserId = status.InReplyToUserId;
+
+                if (favTweet)
+                {
+                    post.IsFav = true;
+                }
+                else
+                {
+                    // 幻覚fav対策
+                    var favTab = this.tabinfo.FavoriteTab;
+                    post.IsFav = favTab.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
+                }
+
+                if (status.Coordinates != null)
+                    post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
+
+                // 以下、ユーザー情報
+                var user = status.User;
+                if (user != null)
+                {
+                    post.UserId = user.Id;
+                    post.ScreenName = user.ScreenName;
+                    post.Nickname = user.Name.Trim();
+                    post.ImageUrl = user.ProfileImageUrlHttps;
+                    post.IsProtect = user.Protected;
+                    post.IsMe = post.UserId == selfUserId;
+                }
+                else
+                {
+                    post.UserId = 0L;
+                    post.ScreenName = "?????";
+                    post.Nickname = "Unknown User";
+                }
+            }
+            // HTMLに整形
+            var textFromApi = post.TextFromApi;
+
+            var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
+
+            if (quotedStatusLink != null && entities.Urls != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
+                quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
+
+            post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
+            post.TextFromApi = textFromApi;
+            post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
+            post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
+            post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
+            post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
+            post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
+            post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
+
+            this.ExtractEntities(entities, post.ReplyToList, post.Media);
+
+            post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
+                .Where(x => x != post.StatusId && x != post.RetweetedId)
+                .Distinct().ToArray();
+
+            post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
+                .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
+                .ToArray();
+
+            // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
+            if (post.Text == post.TextFromApi)
+                post.Text = post.TextFromApi;
+            if (post.AccessibleText == post.TextFromApi)
+                post.AccessibleText = post.TextFromApi;
+
+            // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
+            post.ScreenName = string.Intern(post.ScreenName);
+            post.Nickname = string.Intern(post.Nickname);
+            post.ImageUrl = string.Intern(post.ImageUrl);
+            post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
+
+            // Source整形
+            var (sourceText, sourceUri) = ParseSource(sourceHtml);
+            post.Source = string.Intern(sourceText);
+            post.SourceUri = sourceUri;
+
+            post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == selfUserId);
+            post.IsExcludeReply = false;
+
+            if (post.IsMe)
+            {
+                post.IsOwl = false;
+            }
+            else
+            {
+                if (followerIds.Count > 0)
+                    post.IsOwl = !followerIds.Contains(post.UserId);
+            }
+
+            post.IsDm = false;
+            return post;
+        }
+
+        public PostClass CreateFromDirectMessageEvent(
+            TwitterMessageEvent eventItem,
+            IReadOnlyDictionary<string, TwitterUser> users,
+            IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
+            long selfUserId
+        )
+        {
+            var post = new PostClass();
+            post.StatusId = long.Parse(eventItem.Id);
+
+            var timestamp = long.Parse(eventItem.CreatedTimestamp);
+            post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
+            // 本文
+            var textFromApi = eventItem.MessageCreate.MessageData.Text;
+
+            var entities = eventItem.MessageCreate.MessageData.Entities;
+            var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
+
+            if (mediaEntity != null)
+                entities.Media = new[] { mediaEntity };
+
+            // HTMLに整形
+            post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
+            post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
+            post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
+            post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
+            post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
+            post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
+            post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
+            post.IsFav = false;
+
+            this.ExtractEntities(entities, post.ReplyToList, post.Media);
+
+            post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
+                .Distinct().ToArray();
+
+            post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
+                .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
+                .ToArray();
+
+            // 以下、ユーザー情報
+            string userId;
+            if (eventItem.MessageCreate.SenderId != selfUserId.ToString(CultureInfo.InvariantCulture))
+            {
+                userId = eventItem.MessageCreate.SenderId;
+                post.IsMe = false;
+                post.IsOwl = true;
+            }
+            else
+            {
+                userId = eventItem.MessageCreate.Target.RecipientId;
+                post.IsMe = true;
+                post.IsOwl = false;
+            }
+
+            if (users.TryGetValue(userId, out var user))
+            {
+                post.UserId = user.Id;
+                post.ScreenName = user.ScreenName;
+                post.Nickname = user.Name.Trim();
+                post.ImageUrl = user.ProfileImageUrlHttps;
+                post.IsProtect = user.Protected;
+            }
+            else
+            {
+                post.UserId = 0L;
+                post.ScreenName = "?????";
+                post.Nickname = "Unknown User";
+            }
+
+            // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
+            if (post.Text == post.TextFromApi)
+                post.Text = post.TextFromApi;
+            if (post.AccessibleText == post.TextFromApi)
+                post.AccessibleText = post.TextFromApi;
+
+            // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
+            post.ScreenName = string.Intern(post.ScreenName);
+            post.Nickname = string.Intern(post.Nickname);
+            post.ImageUrl = string.Intern(post.ImageUrl);
+
+            var appId = eventItem.MessageCreate.SourceAppId;
+            if (appId != null && apps.TryGetValue(appId, out var app))
+            {
+                post.Source = string.Intern(app.Name);
+
+                try
+                {
+                    post.SourceUri = new Uri(SourceUriBase, app.Url);
+                }
+                catch (UriFormatException)
+                {
+                }
+            }
+
+            post.IsReply = false;
+            post.IsExcludeReply = false;
+            post.IsDm = true;
+
+            return post;
+        }
+
+        private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
+        {
+            if (entities?.Urls != null)
+            {
+                foreach (var m in entities.Urls)
+                {
+                    if (!MyCommon.IsNullOrEmpty(m.DisplayUrl))
+                        text = text.Replace(m.Url, m.DisplayUrl);
+                }
+            }
+
+            if (entities?.Media != null)
+            {
+                foreach (var m in entities.Media)
+                {
+                    if (!MyCommon.IsNullOrEmpty(m.DisplayUrl))
+                        text = text.Replace(m.Url, m.DisplayUrl);
+                }
+            }
+
+            if (quotedStatusLink != null)
+                text += " " + quotedStatusLink.Display;
+
+            return text;
+        }
+
+        private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List<MediaInfo> media)
+        {
+            if (entities == null)
+                return;
+
+            if (entities.Hashtags != null)
+            {
+                var hashtags = entities.Hashtags.Select(x => $"#{x.Text}");
+
+                lock (this.receivedHashTags)
+                    this.receivedHashTags.UnionWith(hashtags);
+            }
+
+            if (entities.UserMentions != null)
+            {
+                foreach (var ent in entities.UserMentions)
+                    atList.Add((ent.Id, ent.ScreenName));
+            }
+
+            if (entities.Media != null)
+            {
+                if (media != null)
+                {
+                    foreach (var ent in entities.Media)
+                    {
+                        if (media.Any(x => x.Url == ent.MediaUrlHttps))
+                            continue;
+
+                        var videoUrl =
+                            ent.VideoInfo != null && ent.Type == "animated_gif" || ent.Type == "video"
+                            ? ent.ExpandedUrl
+                            : null;
+
+                        var mediaInfo = new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl);
+                        media.Add(mediaInfo);
+                    }
+                }
+            }
+        }
+
+        private static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
+        {
+            if (entities == null)
+                return text;
+
+            if (entities.Urls != null)
+            {
+                foreach (var entity in entities.Urls)
+                {
+                    if (quotedStatus != null)
+                    {
+                        var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
+                        if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
+                        {
+                            var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
+                            text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
+                            continue;
+                        }
+                    }
+
+                    if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
+                        text = text.Replace(entity.Url, entity.DisplayUrl);
+                }
+            }
+
+            if (entities.Media != null)
+            {
+                foreach (var entity in entities.Media)
+                {
+                    if (!MyCommon.IsNullOrEmpty(entity.AltText))
+                    {
+                        text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
+                    }
+                    else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
+                    {
+                        text = text.Replace(entity.Url, entity.DisplayUrl);
+                    }
+                }
+            }
+
+            if (quotedStatus != null && quotedStatusLink != null)
+            {
+                var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
+                text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
+            }
+
+            return text;
+        }
+
+        internal static string CreateHtmlAnchor(string text, TwitterEntities entities, TwitterQuotedStatusPermalink? quotedStatusLink)
+        {
+            var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
+
+            // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
+            text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
+
+            text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
+            text = PreProcessUrl(text); // IDN置換
+
+            if (quotedStatusLink != null)
+            {
+                text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
+                    WebUtility.HtmlEncode(quotedStatusLink.Expanded),
+                    WebUtility.HtmlEncode(quotedStatusLink.Display));
+            }
+
+            return text;
+        }
+
+        private static string PreProcessUrl(string orgData)
+        {
+            int posl1;
+            var posl2 = 0;
+            var href = "<a href=\"";
+
+            while (true)
+            {
+                if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
+                {
+                    // IDN展開
+                    posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
+                    posl1 += href.Length;
+                    posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
+                    var urlStr = orgData.Substring(posl1, posl2 - posl1);
+
+                    if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
+                        && !urlStr.StartsWith("https://", StringComparison.Ordinal)
+                        && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
+                    {
+                        continue;
+                    }
+
+                    var replacedUrl = MyCommon.IDNEncode(urlStr);
+                    if (replacedUrl == null) continue;
+                    if (replacedUrl == urlStr) continue;
+
+                    orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
+                    posl2 = 0;
+                }
+                else
+                {
+                    break;
+                }
+            }
+            return orgData;
+        }
+
+        /// <summary>
+        /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
+        /// </summary>
+        public static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
+        {
+            if (MyCommon.IsNullOrEmpty(sourceHtml))
+                return ("", null);
+
+            string sourceText;
+            Uri? sourceUri;
+
+            // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
+
+            var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
+            if (match.Success)
+            {
+                sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
+                try
+                {
+                    var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
+                    sourceUri = new Uri(SourceUriBase, uriStr);
+                }
+                catch (UriFormatException)
+                {
+                    sourceUri = null;
+                }
+            }
+            else
+            {
+                sourceText = WebUtility.HtmlDecode(sourceHtml);
+                sourceUri = null;
+            }
+
+            return (sourceText, sourceUri);
+        }
+
+        /// <summary>
+        /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
+        /// </summary>
+        public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
+        {
+            entities ??= Enumerable.Empty<TwitterEntity>();
+
+            var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
+
+            if (quotedStatusLink != null)
+                urls = urls.Append(quotedStatusLink.Expanded);
+
+            return GetQuoteTweetStatusIds(urls);
+        }
+
+        public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
+        {
+            foreach (var url in urls)
+            {
+                var match = Twitter.StatusUrlRegex.Match(url);
+                if (match.Success)
+                {
+                    if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
+                        yield return statusId;
+                }
+            }
+        }
+    }
+}
index 907c3e7..cb4691f 100644 (file)
     <Compile Include="ApplicationInstanceMutex.cs" />
     <Compile Include="ApplicationPreconditions.cs" />
     <Compile Include="ApplicationSettings.cs" />
+    <Compile Include="Models\TwitterPostFactory.cs" />
     <Compile Include="ThemeManager.cs" />
     <Compile Include="IconAssetsManager.cs" />
     <Compile Include="AsyncTimer.cs" />
index 246894f..1a0ae7d 100644 (file)
@@ -30,7 +30,6 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
-using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net;
@@ -172,8 +171,7 @@ namespace OpenTween
         private ISet<long> followerId = new HashSet<long>();
         private long[] noRTId = Array.Empty<long>();
 
-        // プロパティからアクセスされる共通情報
-        private readonly List<string> hashList = new();
+        private readonly TwitterPostFactory postFactory;
 
         private string? nextCursorDirectMessage = null;
 
@@ -181,6 +179,8 @@ namespace OpenTween
 
         public Twitter(TwitterApi api)
         {
+            this.postFactory = new(TabInformations.GetInstance());
+
             this.Api = api;
             this.Configuration = TwitterConfiguration.DefaultConfiguration();
             this.TextConfiguration = TwitterTextConfiguration.DefaultConfiguration();
@@ -229,44 +229,6 @@ namespace OpenTween
             this.Api.Initialize(token, tokenSecret, userId, username);
         }
 
-        internal static string PreProcessUrl(string orgData)
-        {
-            int posl1;
-            var posl2 = 0;
-            var href = "<a href=\"";
-
-            while (true)
-            {
-                if (orgData.IndexOf(href, posl2, StringComparison.Ordinal) > -1)
-                {
-                    // IDN展開
-                    posl1 = orgData.IndexOf(href, posl2, StringComparison.Ordinal);
-                    posl1 += href.Length;
-                    posl2 = orgData.IndexOf("\"", posl1, StringComparison.Ordinal);
-                    var urlStr = orgData.Substring(posl1, posl2 - posl1);
-
-                    if (!urlStr.StartsWith("http://", StringComparison.Ordinal)
-                        && !urlStr.StartsWith("https://", StringComparison.Ordinal)
-                        && !urlStr.StartsWith("ftp://", StringComparison.Ordinal))
-                    {
-                        continue;
-                    }
-
-                    var replacedUrl = MyCommon.IDNEncode(urlStr);
-                    if (replacedUrl == null) continue;
-                    if (replacedUrl == urlStr) continue;
-
-                    orgData = orgData.Replace("<a href=\"" + urlStr, "<a href=\"" + replacedUrl);
-                    posl2 = 0;
-                }
-                else
-                {
-                    break;
-                }
-            }
-            return orgData;
-        }
-
         public async Task<PostClass?> PostStatus(PostStatusParams param)
         {
             this.CheckAccountState();
@@ -653,206 +615,10 @@ namespace OpenTween
         }
 
         private PostClass CreatePostsFromStatusData(TwitterStatus status)
-            => this.CreatePostsFromStatusData(status, false);
+            => this.CreatePostsFromStatusData(status, favTweet: false);
 
         private PostClass CreatePostsFromStatusData(TwitterStatus status, bool favTweet)
-        {
-            var post = new PostClass();
-            TwitterEntities entities;
-            string sourceHtml;
-
-            post.StatusId = status.Id;
-            if (status.RetweetedStatus != null)
-            {
-                var retweeted = status.RetweetedStatus;
-
-                post.CreatedAt = MyCommon.DateTimeParse(retweeted.CreatedAt);
-
-                // Id
-                post.RetweetedId = retweeted.Id;
-                // 本文
-                post.TextFromApi = retweeted.FullText;
-                entities = retweeted.MergedEntities;
-                sourceHtml = retweeted.Source;
-                // Reply先
-                post.InReplyToStatusId = retweeted.InReplyToStatusId;
-                post.InReplyToUser = retweeted.InReplyToScreenName;
-                post.InReplyToUserId = status.InReplyToUserId;
-
-                if (favTweet)
-                {
-                    post.IsFav = true;
-                }
-                else
-                {
-                    // 幻覚fav対策
-                    var tc = TabInformations.GetInstance().FavoriteTab;
-                    post.IsFav = tc.Contains(retweeted.Id);
-                }
-
-                if (retweeted.Coordinates != null)
-                    post.PostGeo = new PostClass.StatusGeo(retweeted.Coordinates.Coordinates[0], retweeted.Coordinates.Coordinates[1]);
-
-                // 以下、ユーザー情報
-                var user = retweeted.User;
-                if (user != null)
-                {
-                    post.UserId = user.Id;
-                    post.ScreenName = user.ScreenName;
-                    post.Nickname = user.Name.Trim();
-                    post.ImageUrl = user.ProfileImageUrlHttps;
-                    post.IsProtect = user.Protected;
-                }
-                else
-                {
-                    post.UserId = 0L;
-                    post.ScreenName = "?????";
-                    post.Nickname = "Unknown User";
-                }
-
-                // Retweetした人
-                if (status.User != null)
-                {
-                    post.RetweetedBy = status.User.ScreenName;
-                    post.RetweetedByUserId = status.User.Id;
-                    post.IsMe = post.RetweetedByUserId == this.UserId;
-                }
-                else
-                {
-                    post.RetweetedBy = "?????";
-                    post.RetweetedByUserId = 0L;
-                }
-            }
-            else
-            {
-                post.CreatedAt = MyCommon.DateTimeParse(status.CreatedAt);
-                // 本文
-                post.TextFromApi = status.FullText;
-                entities = status.MergedEntities;
-                sourceHtml = status.Source;
-                post.InReplyToStatusId = status.InReplyToStatusId;
-                post.InReplyToUser = status.InReplyToScreenName;
-                post.InReplyToUserId = status.InReplyToUserId;
-
-                if (favTweet)
-                {
-                    post.IsFav = true;
-                }
-                else
-                {
-                    // 幻覚fav対策
-                    var tc = TabInformations.GetInstance().FavoriteTab;
-                    post.IsFav = tc.Posts.TryGetValue(post.StatusId, out var tabinfoPost) && tabinfoPost.IsFav;
-                }
-
-                if (status.Coordinates != null)
-                    post.PostGeo = new PostClass.StatusGeo(status.Coordinates.Coordinates[0], status.Coordinates.Coordinates[1]);
-
-                // 以下、ユーザー情報
-                var user = status.User;
-                if (user != null)
-                {
-                    post.UserId = user.Id;
-                    post.ScreenName = user.ScreenName;
-                    post.Nickname = user.Name.Trim();
-                    post.ImageUrl = user.ProfileImageUrlHttps;
-                    post.IsProtect = user.Protected;
-                    post.IsMe = post.UserId == this.UserId;
-                }
-                else
-                {
-                    post.UserId = 0L;
-                    post.ScreenName = "?????";
-                    post.Nickname = "Unknown User";
-                }
-            }
-            // HTMLに整形
-            var textFromApi = post.TextFromApi;
-
-            var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink;
-
-            if (quotedStatusLink != null && entities.Urls.Any(x => x.ExpandedUrl == quotedStatusLink.Expanded))
-                quotedStatusLink = null; // 移行期は entities.urls と quoted_status_permalink の両方に含まれる場合がある
-
-            post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink);
-            post.TextFromApi = textFromApi;
-            post.TextFromApi = this.ReplaceTextFromApi(post.TextFromApi, entities, quotedStatusLink);
-            post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
-            post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
-            post.AccessibleText = CreateAccessibleText(textFromApi, entities, (status.RetweetedStatus ?? status).QuotedStatus, quotedStatusLink);
-            post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
-            post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
-
-            this.ExtractEntities(entities, post.ReplyToList, post.Media);
-
-            post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink)
-                .Where(x => x != post.StatusId && x != post.RetweetedId)
-                .Distinct().ToArray();
-
-            post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
-                .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
-                .ToArray();
-
-            // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
-            if (post.Text == post.TextFromApi)
-                post.Text = post.TextFromApi;
-            if (post.AccessibleText == post.TextFromApi)
-                post.AccessibleText = post.TextFromApi;
-
-            // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
-            post.ScreenName = string.Intern(post.ScreenName);
-            post.Nickname = string.Intern(post.Nickname);
-            post.ImageUrl = string.Intern(post.ImageUrl);
-            post.RetweetedBy = post.RetweetedBy != null ? string.Intern(post.RetweetedBy) : null;
-
-            // Source整形
-            var (sourceText, sourceUri) = ParseSource(sourceHtml);
-            post.Source = string.Intern(sourceText);
-            post.SourceUri = sourceUri;
-
-            post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == this.UserId);
-            post.IsExcludeReply = false;
-
-            if (post.IsMe)
-            {
-                post.IsOwl = false;
-            }
-            else
-            {
-                if (this.followerId.Count > 0) post.IsOwl = !this.followerId.Contains(post.UserId);
-            }
-
-            post.IsDm = false;
-            return post;
-        }
-
-        /// <summary>
-        /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出
-        /// </summary>
-        public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<TwitterEntity>? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
-        {
-            entities ??= Enumerable.Empty<TwitterEntity>();
-
-            var urls = entities.OfType<TwitterEntityUrl>().Select(x => x.ExpandedUrl);
-
-            if (quotedStatusLink != null)
-                urls = urls.Append(quotedStatusLink.Expanded);
-
-            return GetQuoteTweetStatusIds(urls);
-        }
-
-        public static IEnumerable<long> GetQuoteTweetStatusIds(IEnumerable<string> urls)
-        {
-            foreach (var url in urls)
-            {
-                var match = Twitter.StatusUrlRegex.Match(url);
-                if (match.Success)
-                {
-                    if (long.TryParse(match.Groups["StatusId"].Value, out var statusId))
-                        yield return statusId;
-                }
-            }
-        }
+            => this.postFactory.CreateFromStatus(status, this.UserId, this.followerId, favTweet);
 
         private long? CreatePostsFromJson(TwitterStatus[] items, MyCommon.WORKERTYPE gType, TabModel? tab, bool read)
         {
@@ -1226,98 +992,16 @@ namespace OpenTween
             IReadOnlyDictionary<string, TwitterMessageEventList.App> apps,
             bool read)
         {
+            var dmTab = TabInformations.GetInstance().DirectMessageTab;
+
             foreach (var eventItem in events)
             {
-                var post = new PostClass();
-                post.StatusId = long.Parse(eventItem.Id);
-
-                var timestamp = long.Parse(eventItem.CreatedTimestamp);
-                post.CreatedAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond);
-                // 本文
-                var textFromApi = eventItem.MessageCreate.MessageData.Text;
-
-                var entities = eventItem.MessageCreate.MessageData.Entities;
-                var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media;
-
-                if (mediaEntity != null)
-                    entities.Media = new[] { mediaEntity };
-
-                // HTMLに整形
-                post.Text = CreateHtmlAnchor(textFromApi, entities, quotedStatusLink: null);
-                post.TextFromApi = this.ReplaceTextFromApi(textFromApi, entities, quotedStatusLink: null);
-                post.TextFromApi = WebUtility.HtmlDecode(post.TextFromApi);
-                post.TextFromApi = post.TextFromApi.Replace("<3", "\u2661");
-                post.AccessibleText = CreateAccessibleText(textFromApi, entities, quotedStatus: null, quotedStatusLink: null);
-                post.AccessibleText = WebUtility.HtmlDecode(post.AccessibleText);
-                post.AccessibleText = post.AccessibleText.Replace("<3", "\u2661");
-                post.IsFav = false;
-
-                this.ExtractEntities(entities, post.ReplyToList, post.Media);
-
-                post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null)
-                    .Distinct().ToArray();
-
-                post.ExpandedUrls = entities.OfType<TwitterEntityUrl>()
-                    .Select(x => new PostClass.ExpandedUrlInfo(x.Url, x.ExpandedUrl))
-                    .ToArray();
-
-                // 以下、ユーザー情報
-                string userId;
-                if (eventItem.MessageCreate.SenderId != this.Api.CurrentUserId.ToString(CultureInfo.InvariantCulture))
-                {
-                    userId = eventItem.MessageCreate.SenderId;
-                    post.IsMe = false;
-                    post.IsOwl = true;
-                }
-                else
-                {
-                    userId = eventItem.MessageCreate.Target.RecipientId;
-                    post.IsMe = true;
-                    post.IsOwl = false;
-                }
-
-                if (!users.TryGetValue(userId, out var user))
-                    continue;
-
-                post.UserId = user.Id;
-                post.ScreenName = user.ScreenName;
-                post.Nickname = user.Name.Trim();
-                post.ImageUrl = user.ProfileImageUrlHttps;
-                post.IsProtect = user.Protected;
-
-                // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる)
-                if (post.Text == post.TextFromApi)
-                    post.Text = post.TextFromApi;
-                if (post.AccessibleText == post.TextFromApi)
-                    post.AccessibleText = post.TextFromApi;
-
-                // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す
-                post.ScreenName = string.Intern(post.ScreenName);
-                post.Nickname = string.Intern(post.Nickname);
-                post.ImageUrl = string.Intern(post.ImageUrl);
-
-                var appId = eventItem.MessageCreate.SourceAppId;
-                if (appId != null && apps.TryGetValue(appId, out var app))
-                {
-                    post.Source = string.Intern(app.Name);
-
-                    try
-                    {
-                        post.SourceUri = new Uri(SourceUriBase, app.Url);
-                    }
-                    catch (UriFormatException)
-                    {
-                    }
-                }
+                var post = this.postFactory.CreateFromDirectMessageEvent(eventItem, users, apps, this.UserId);
 
                 post.IsRead = read;
                 if (post.IsMe && !read && this.ReadOwnPost)
                     post.IsRead = true;
-                post.IsReply = false;
-                post.IsExcludeReply = false;
-                post.IsDm = true;
 
-                var dmTab = TabInformations.GetInstance().DirectMessageTab;
                 dmTab.AddPostQueue(post);
             }
         }
@@ -1346,81 +1030,6 @@ namespace OpenTween
                 tab.OldestId = minimumId.Value;
         }
 
-        private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
-        {
-            if (entities != null)
-            {
-                if (entities.Urls != null)
-                {
-                    foreach (var m in entities.Urls)
-                    {
-                        if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
-                    }
-                }
-                if (entities.Media != null)
-                {
-                    foreach (var m in entities.Media)
-                    {
-                        if (!MyCommon.IsNullOrEmpty(m.DisplayUrl)) text = text.Replace(m.Url, m.DisplayUrl);
-                    }
-                }
-            }
-
-            if (quotedStatusLink != null)
-                text += " " + quotedStatusLink.Display;
-
-            return text;
-        }
-
-        internal static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink)
-        {
-            if (entities == null)
-                return text;
-
-            if (entities.Urls != null)
-            {
-                foreach (var entity in entities.Urls)
-                {
-                    if (quotedStatus != null)
-                    {
-                        var matchStatusUrl = Twitter.StatusUrlRegex.Match(entity.ExpandedUrl);
-                        if (matchStatusUrl.Success && matchStatusUrl.Groups["StatusId"].Value == quotedStatus.IdStr)
-                        {
-                            var quotedText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
-                            text = text.Replace(entity.Url, string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quotedText));
-                            continue;
-                        }
-                    }
-
-                    if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
-                        text = text.Replace(entity.Url, entity.DisplayUrl);
-                }
-            }
-
-            if (entities.Media != null)
-            {
-                foreach (var entity in entities.Media)
-                {
-                    if (!MyCommon.IsNullOrEmpty(entity.AltText))
-                    {
-                        text = text.Replace(entity.Url, string.Format(Properties.Resources.ImageAltText, entity.AltText));
-                    }
-                    else if (!MyCommon.IsNullOrEmpty(entity.DisplayUrl))
-                    {
-                        text = text.Replace(entity.Url, entity.DisplayUrl);
-                    }
-                }
-            }
-
-            if (quotedStatus != null && quotedStatusLink != null)
-            {
-                var quoteText = CreateAccessibleText(quotedStatus.FullText, quotedStatus.MergedEntities, quotedStatus: null, quotedStatusLink: null);
-                text += " " + string.Format(Properties.Resources.QuoteStatus_AccessibleText, quotedStatus.User.ScreenName, quoteText);
-            }
-
-            return text;
-        }
-
         /// <summary>
         /// フォロワーIDを更新します
         /// </summary>
@@ -1561,106 +1170,6 @@ namespace OpenTween
             }
         }
 
-        private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List<MediaInfo> media)
-        {
-            if (entities != null)
-            {
-                if (entities.Hashtags != null)
-                {
-                    lock (this.lockObj)
-                    {
-                        this.hashList.AddRange(entities.Hashtags.Select(x => "#" + x.Text));
-                    }
-                }
-                if (entities.UserMentions != null)
-                {
-                    foreach (var ent in entities.UserMentions)
-                    {
-                        atList.Add((ent.Id, ent.ScreenName));
-                    }
-                }
-                if (entities.Media != null)
-                {
-                    if (media != null)
-                    {
-                        foreach (var ent in entities.Media)
-                        {
-                            if (!media.Any(x => x.Url == ent.MediaUrlHttps))
-                            {
-                                if (ent.VideoInfo != null &&
-                                    ent.Type == "animated_gif" || ent.Type == "video")
-                                {
-                                    media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, ent.ExpandedUrl));
-                                }
-                                else
-                                {
-                                    media.Add(new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl: null));
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        internal static string CreateHtmlAnchor(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink)
-        {
-            var mergedEntities = entities.Concat(TweetExtractor.ExtractEmojiEntities(text));
-
-            // PostClass.ExpandedUrlInfo を使用して非同期に URL 展開を行うためここでは expanded_url を使用しない
-            text = TweetFormatter.AutoLinkHtml(text, mergedEntities, keepTco: true);
-
-            text = Regex.Replace(text, "(^|[^a-zA-Z0-9_/&##@@>=.~])(sm|nm)([0-9]{1,10})", "$1<a href=\"https://www.nicovideo.jp/watch/$2$3\">$2$3</a>");
-            text = PreProcessUrl(text); // IDN置換
-
-            if (quotedStatusLink != null)
-            {
-                text += string.Format(" <a href=\"{0}\" title=\"{0}\">{1}</a>",
-                    WebUtility.HtmlEncode(quotedStatusLink.Url),
-                    WebUtility.HtmlEncode(quotedStatusLink.Display));
-            }
-
-            return text;
-        }
-
-        private static readonly Uri SourceUriBase = new("https://twitter.com/");
-
-        /// <summary>
-        /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します
-        /// </summary>
-        internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml)
-        {
-            if (MyCommon.IsNullOrEmpty(sourceHtml))
-                return ("", null);
-
-            string sourceText;
-            Uri? sourceUri;
-
-            // sourceHtmlの例: <a href="http://twitter.com" rel="nofollow">Twitter Web Client</a>
-
-            var match = Regex.Match(sourceHtml, "^<a href=\"(?<uri>.+?)\".*?>(?<text>.+)</a>$", RegexOptions.IgnoreCase);
-            if (match.Success)
-            {
-                sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value);
-                try
-                {
-                    var uriStr = WebUtility.HtmlDecode(match.Groups["uri"].Value);
-                    sourceUri = new Uri(SourceUriBase, uriStr);
-                }
-                catch (UriFormatException)
-                {
-                    sourceUri = null;
-                }
-            }
-            else
-            {
-                sourceText = WebUtility.HtmlDecode(sourceHtml);
-                sourceUri = null;
-            }
-
-            return (sourceText, sourceUri);
-        }
-
         public async Task<TwitterApiStatus?> GetInfoApi()
         {
             if (Twitter.AccountState != MyCommon.ACCOUNT_STATE.Valid) return null;
@@ -1716,15 +1225,7 @@ namespace OpenTween
         }
 
         public string[] GetHashList()
-        {
-            string[] hashArray;
-            lock (this.lockObj)
-            {
-                hashArray = this.hashList.ToArray();
-                this.hashList.Clear();
-            }
-            return hashArray;
-        }
+            => this.postFactory.GetReceivedHashtags();
 
         public string AccessToken
             => ((TwitterApiConnection)this.Api.Connection).AccessToken;