From 26561242a3298d4eca668f7352a43d8b5ee6c702 Mon Sep 17 00:00:00 2001 From: Kimura Youichi Date: Sun, 29 May 2022 07:04:58 +0900 Subject: [PATCH] =?utf8?q?PostClass=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF?= =?utf8?q?=E3=83=B3=E3=82=B9=E3=81=AE=E7=94=9F=E6=88=90=E5=87=A6=E7=90=86?= =?utf8?q?=E3=82=92TwitterPostFactory=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AB?= =?utf8?q?=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- OpenTween.Tests/Models/PostClassTest.cs | 2 + OpenTween.Tests/Models/TwitterPostFactoryTest.cs | 643 +++++++++++++++++++++++ OpenTween.Tests/TwitterTest.cs | 318 ----------- OpenTween/Models/PostClass.cs | 4 +- OpenTween/Models/TabInformations.cs | 2 +- OpenTween/Models/TwitterPostFactory.cs | 570 ++++++++++++++++++++ OpenTween/OpenTween.csproj | 1 + OpenTween/Twitter.cs | 517 +----------------- 8 files changed, 1229 insertions(+), 828 deletions(-) create mode 100644 OpenTween.Tests/Models/TwitterPostFactoryTest.cs create mode 100644 OpenTween/Models/TwitterPostFactory.cs diff --git a/OpenTween.Tests/Models/PostClassTest.cs b/OpenTween.Tests/Models/PostClassTest.cs index e75575fb..eebcf63e 100644 --- a/OpenTween.Tests/Models/PostClassTest.cs +++ b/OpenTween.Tests/Models/PostClassTest.cs @@ -417,6 +417,8 @@ namespace OpenTween.Models [Fact] public async Task ExpandedUrls_BasicScenario() { + PostClass.ExpandedUrlInfo.AutoExpand = true; + var post = new PostClass { Text = "bit.ly/abcde", diff --git a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs new file mode 100644 index 00000000..a62d8a34 --- /dev/null +++ b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs @@ -0,0 +1,643 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2013 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 System.Collections.Generic; +using OpenTween.Api.DataModel; +using Xunit; + +namespace OpenTween.Models +{ + public class TwitterPostFactoryTest + { + private static readonly ISet EmptyIdSet = new HashSet(); + + 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 = "OpenTween", + 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 { 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 { 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 = "Twitter Web App"; + + 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 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() + { + [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() + { + [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("pic.twitter.com/hoge", 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("pic.twitter.com/hoge", 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("twitter.com/hoge/status/1…", 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 twitter.com/hoge/status/1…", 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("twitter.com/hoge/status/1…", 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 = @"@twitterapi" + + @" #BreakingMyTwitter" + + @" apps-of-a-feather.com"; + + Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink: null)); + } + + [Fact] + public void CreateHtmlAnchor_NicovideoTest() + { + var text = "sm9"; + var entities = new TwitterEntities(); + + var expectedHtml = @"sm9"; + + 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" + + @" twitter.com/hoge/status/1…"; + + Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink)); + } + + [Fact] + public void ParseSource_Test() + { + var sourceHtml = "Twitter Web Client"; + + 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 = "erased_45416"; + + 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 = "<<hogehoge>>"; + + var expected = ("<>", new Uri("http://example.com/?aaa=123&bbb=456")); + Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml)); + } + + [Fact] + public void ParseSource_UnescapeNoUriTest() + { + var sourceHtml = "<<hogehoge>>"; + + var expected = ("<>", (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); + } + } +} diff --git a/OpenTween.Tests/TwitterTest.cs b/OpenTween.Tests/TwitterTest.cs index 7da053a4..6794f178 100644 --- a/OpenTween.Tests/TwitterTest.cs +++ b/OpenTween.Tests/TwitterTest.cs @@ -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 = @"@twitterapi" - + @" #BreakingMyTwitter" - + @" apps-of-a-feather.com"; - - Assert.Equal(expectedHtml, Twitter.CreateHtmlAnchor(text, entities, quotedStatusLink: null)); - } - - [Fact] - public void CreateHtmlAnchor_NicovideoTest() - { - var text = "sm9"; - var entities = new TwitterEntities(); - - var expectedHtml = @"sm9"; - - 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" - + @" twitter.com/hoge/status/1…"; - - Assert.Equal(expectedHtml, Twitter.CreateHtmlAnchor(text, entities, quotedStatusLink)); - } - - [Fact] - public void ParseSource_Test() - { - var sourceHtml = "Twitter Web Client"; - - 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 = "erased_45416"; - - 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 = "<<hogehoge>>"; - - var expected = ("<>", new Uri("http://example.com/?aaa=123&bbb=456")); - Assert.Equal(expected, Twitter.ParseSource(sourceHtml)); - } - - [Fact] - public void ParseSource_UnescapeNoUriTest() - { - var sourceHtml = "<<hogehoge>>"; - - var expected = ("<>", (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; diff --git a/OpenTween/Models/PostClass.cs b/OpenTween/Models/PostClass.cs index b91ab0a6..e52e333f 100644 --- a/OpenTween/Models/PostClass.cs +++ b/OpenTween/Models/PostClass.cs @@ -134,6 +134,8 @@ namespace OpenTween.Models /// public class ExpandedUrlInfo : ICloneable { + public static bool AutoExpand { get; set; } = true; + /// 展開前の t.co ドメインの URL 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; diff --git a/OpenTween/Models/TabInformations.cs b/OpenTween/Models/TabInformations.cs index a9769d3d..1eac80f6 100644 --- a/OpenTween/Models/TabInformations.cs +++ b/OpenTween/Models/TabInformations.cs @@ -82,7 +82,7 @@ namespace OpenTween.Models // List private List lists = new(); - private TabInformations() + internal TabInformations() { } diff --git a/OpenTween/Models/TwitterPostFactory.cs b/OpenTween/Models/TwitterPostFactory.cs new file mode 100644 index 00000000..b792b4bd --- /dev/null +++ b/OpenTween/Models/TwitterPostFactory.cs @@ -0,0 +1,570 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2022 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.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 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 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() + .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 users, + IReadOnlyDictionary 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() + .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 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$2$3"); + text = PreProcessUrl(text); // IDN置換 + + if (quotedStatusLink != null) + { + text += string.Format(" {1}", + WebUtility.HtmlEncode(quotedStatusLink.Expanded), + WebUtility.HtmlEncode(quotedStatusLink.Display)); + } + + return text; + } + + private static string PreProcessUrl(string orgData) + { + int posl1; + var posl2 = 0; + var href = " -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(" + /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します + /// + public static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml) + { + if (MyCommon.IsNullOrEmpty(sourceHtml)) + return ("", null); + + string sourceText; + Uri? sourceUri; + + // sourceHtmlの例: Twitter Web Client + + var match = Regex.Match(sourceHtml, "^.+?)\".*?>(?.+)$", 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); + } + + /// + /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出 + /// + public static IEnumerable GetQuoteTweetStatusIds(IEnumerable? entities, TwitterQuotedStatusPermalink? quotedStatusLink) + { + entities ??= Enumerable.Empty(); + + var urls = entities.OfType().Select(x => x.ExpandedUrl); + + if (quotedStatusLink != null) + urls = urls.Append(quotedStatusLink.Expanded); + + return GetQuoteTweetStatusIds(urls); + } + + public static IEnumerable GetQuoteTweetStatusIds(IEnumerable 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; + } + } + } + } +} diff --git a/OpenTween/OpenTween.csproj b/OpenTween/OpenTween.csproj index 907c3e7d..cb4691fd 100644 --- a/OpenTween/OpenTween.csproj +++ b/OpenTween/OpenTween.csproj @@ -110,6 +110,7 @@ + diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs index 246894fa..1a0ae7dc 100644 --- a/OpenTween/Twitter.cs +++ b/OpenTween/Twitter.cs @@ -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 followerId = new HashSet(); private long[] noRTId = Array.Empty(); - // プロパティからアクセスされる共通情報 - private readonly List 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 = " -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(" 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() - .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; - } - - /// - /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出 - /// - public static IEnumerable GetQuoteTweetStatusIds(IEnumerable? entities, TwitterQuotedStatusPermalink? quotedStatusLink) - { - entities ??= Enumerable.Empty(); - - var urls = entities.OfType().Select(x => x.ExpandedUrl); - - if (quotedStatusLink != null) - urls = urls.Append(quotedStatusLink.Expanded); - - return GetQuoteTweetStatusIds(urls); - } - - public static IEnumerable GetQuoteTweetStatusIds(IEnumerable 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 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() - .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; - } - /// /// フォロワーIDを更新します /// @@ -1561,106 +1170,6 @@ namespace OpenTween } } - private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List 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$2$3"); - text = PreProcessUrl(text); // IDN置換 - - if (quotedStatusLink != null) - { - text += string.Format(" {1}", - WebUtility.HtmlEncode(quotedStatusLink.Url), - WebUtility.HtmlEncode(quotedStatusLink.Display)); - } - - return text; - } - - private static readonly Uri SourceUriBase = new("https://twitter.com/"); - - /// - /// Twitter APIから得たHTML形式のsource文字列を分析し、source名とURLに分離します - /// - internal static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml) - { - if (MyCommon.IsNullOrEmpty(sourceHtml)) - return ("", null); - - string sourceText; - Uri? sourceUri; - - // sourceHtmlの例: Twitter Web Client - - var match = Regex.Match(sourceHtml, "^.+?)\".*?>(?.+)$", 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 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; -- 2.11.0