diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ff3445c2..58fb98cac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -83,8 +83,8 @@ jobs: - name: Run tests shell: pwsh run: | - $altCoverVersion = '8.2.837' - $xunitVersion = '2.4.1' + $altCoverVersion = '8.6.61' + $xunitVersion = '2.4.2' $targetFramework = 'net48' $altCoverPath = "$($env:NUGET_PACKAGES)\altcover\$($altCoverVersion)\tools\net472\AltCover.exe" $xunitPath = "$($env:NUGET_PACKAGES)\xunit.runner.console\$($xunitVersion)\tools\net472\xunit.console.exe" diff --git a/OpenTween.Tests/Api/BitlyApiTest.cs b/OpenTween.Tests/Api/BitlyApiTest.cs index b40a3ea2b..b8c7f4a66 100644 --- a/OpenTween.Tests/Api/BitlyApiTest.cs +++ b/OpenTween.Tests/Api/BitlyApiTest.cs @@ -132,7 +132,7 @@ public async Task GetAccessTokenAsync_Test() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"access_token\": \"abcdefg\"}"), + Content = new StringContent("""{"access_token": "abcdefg"}"""), }; }); @@ -154,7 +154,7 @@ public async Task GetAccessTokenAsync_ErrorResponseTest() { return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"status_code\": \"500\", \"status_txt\": \"MISSING_ARG_USERNAME\"}"), + Content = new StringContent("""{"status_code": "500", "status_txt": "MISSING_ARG_USERNAME"}"""), }; }); diff --git a/OpenTween.Tests/Api/DataModel/TwitterMessageEventListTest.cs b/OpenTween.Tests/Api/DataModel/TwitterMessageEventListTest.cs index 872c96ed5..91569b11c 100644 --- a/OpenTween.Tests/Api/DataModel/TwitterMessageEventListTest.cs +++ b/OpenTween.Tests/Api/DataModel/TwitterMessageEventListTest.cs @@ -29,16 +29,18 @@ public class TwitterMessageEventListTest [Fact] public void Deserialize_AppsTest() { - var json = @"{ - ""events"": [], - ""apps"": { - ""258901"": { - ""id"": ""258901"", - ""name"": ""Twitter for Android"", - ""url"": ""http://twitter.com/download/android"" - } - } -}"; + var json = """ + { + "events": [], + "apps": { + "258901": { + "id": "258901", + "name": "Twitter for Android", + "url": "http://twitter.com/download/android" + } + } + } + """; var result = MyCommon.CreateDataFromJson(json); Assert.Single(result.Apps); diff --git a/OpenTween.Tests/Api/DataModel/TwitterUser_20190520ChangesTest.cs b/OpenTween.Tests/Api/DataModel/TwitterUser_20190520ChangesTest.cs index e4a99d9d5..cc38ff244 100644 --- a/OpenTween.Tests/Api/DataModel/TwitterUser_20190520ChangesTest.cs +++ b/OpenTween.Tests/Api/DataModel/TwitterUser_20190520ChangesTest.cs @@ -32,52 +32,54 @@ public void ParseJsonTest() // (廃止されるフィールドに null がセットされる) // https://twittercommunity.com/t/124732 - var json = @"{ - ""id"": 6253282, - ""id_str"": ""6253282"", - ""name"": ""Twitter API"", - ""screen_name"": ""TwitterAPI"", - ""location"": ""San Francisco, CA"", - ""profile_location"": null, - ""description"": ""The Real Twitter API. Tweets about API changes, service issues and our Developer Platform. Don't get an answer? It's on my website."", - ""url"": ""https:\/\/t.co\/8IkCzCDr19"", - ""protected"": false, - ""followers_count"": 6133601, - ""friends_count"": 12, - ""listed_count"": 12935, - ""created_at"": ""Wed May 23 06:01:13 +0000 2007"", - ""favourites_count"": 31, - ""utc_offset"": null, - ""time_zone"": null, - ""geo_enabled"": null, - ""verified"": true, - ""statuses_count"": 3657, - ""lang"": null, - ""contributors_enabled"": null, - ""is_translator"": null, - ""is_translation_enabled"": null, - ""profile_background_color"": null, - ""profile_background_image_url"": null, - ""profile_background_image_url_https"": null, - ""profile_background_tile"": null, - ""profile_image_url"": null, - ""profile_image_url_https"": ""https:\/\/pbs.twimg.com\/profile_images\/942858479592554497\/BbazLO9L_normal.jpg"", - ""profile_banner_url"": ""https:\/\/pbs.twimg.com\/profile_banners\/6253282\/1497491515"", - ""profile_image_extensions_alt_text"": null, - ""profile_banner_extensions_alt_text"": null, - ""profile_link_color"": null, - ""profile_sidebar_border_color"": null, - ""profile_sidebar_fill_color"": null, - ""profile_text_color"": null, - ""profile_use_background_image"": null, - ""has_extended_profile"": null, - ""default_profile"": false, - ""default_profile_image"": false, - ""following"": null, - ""follow_request_sent"": null, - ""notifications"": null, - ""translator_type"": null -}"; + var json = """ + { + "id": 6253282, + "id_str": "6253282", + "name": "Twitter API", + "screen_name": "TwitterAPI", + "location": "San Francisco, CA", + "profile_location": null, + "description": "The Real Twitter API. Tweets about API changes, service issues and our Developer Platform. Don't get an answer? It's on my website.", + "url": "https:\/\/t.co\/8IkCzCDr19", + "protected": false, + "followers_count": 6133601, + "friends_count": 12, + "listed_count": 12935, + "created_at": "Wed May 23 06:01:13 +0000 2007", + "favourites_count": 31, + "utc_offset": null, + "time_zone": null, + "geo_enabled": null, + "verified": true, + "statuses_count": 3657, + "lang": null, + "contributors_enabled": null, + "is_translator": null, + "is_translation_enabled": null, + "profile_background_color": null, + "profile_background_image_url": null, + "profile_background_image_url_https": null, + "profile_background_tile": null, + "profile_image_url": null, + "profile_image_url_https": "https:\/\/pbs.twimg.com\/profile_images\/942858479592554497\/BbazLO9L_normal.jpg", + "profile_banner_url": "https:\/\/pbs.twimg.com\/profile_banners\/6253282\/1497491515", + "profile_image_extensions_alt_text": null, + "profile_banner_extensions_alt_text": null, + "profile_link_color": null, + "profile_sidebar_border_color": null, + "profile_sidebar_fill_color": null, + "profile_text_color": null, + "profile_use_background_image": null, + "has_extended_profile": null, + "default_profile": false, + "default_profile_image": false, + "following": null, + "follow_request_sent": null, + "notifications": null, + "translator_type": null + } + """; TwitterUser.ParseJson(json); } } diff --git a/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs b/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs new file mode 100644 index 000000000..c53b27b97 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/ListLatestTweetsTimelineRequestTest.cs @@ -0,0 +1,66 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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 System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using OpenTween.Api.TwitterV2; +using OpenTween.Connection; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class ListLatestTweetsTimelineRequestTest + { + [Fact] + public async Task Send_Test() + { + using var responseStream = File.OpenRead("Resources/Responses/ListLatestTweetsTimeline_SimpleTweet.json"); + + var mock = new Mock(); + mock.Setup(x => + x.GetStreamAsync(It.IsAny(), It.IsAny>()) + ) + .Callback>((url, param) => + { + Assert.Equal(new("https://twitter.com/i/api/graphql/6ClPnsuzQJ1p7-g32GQw9Q/ListLatestTweetsTimeline"), url); + Assert.Equal(2, param.Count); + Assert.Equal("""{"listId":"1675863884757110790","count":20}""", param["variables"]); + Assert.True(param.ContainsKey("features")); + }) + .ReturnsAsync(responseStream); + + var request = new ListLatestTweetsTimelineRequest(listId: "1675863884757110790") + { + Count = 20, + }; + + var tweets = await request.Send(mock.Object).ConfigureAwait(false); + Assert.Single(tweets); + + mock.VerifyAll(); + } + } +} diff --git a/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs b/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs new file mode 100644 index 000000000..b16e97158 --- /dev/null +++ b/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs @@ -0,0 +1,114 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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 System.IO; +using System.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using OpenTween.Models; +using Xunit; + +namespace OpenTween.Api.GraphQL +{ + public class TimelineTweetTest + { + private XElement LoadResponseDocument(string filename) + { + using var stream = File.OpenRead($"Resources/Responses/{filename}"); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); + return XElement.Load(jsonReader); + } + + private TabInformations CreateTabInfo() + { + var tabinfo = new TabInformations(); + tabinfo.AddDefaultTabs(); + return tabinfo; + } + + [Fact] + public void ExtractTimelineTweets_Single_Test() + { + var rootElm = this.LoadResponseDocument("ListLatestTweetsTimeline_SimpleTweet.json"); + var timelineTweets = TimelineTweet.ExtractTimelineTweets(rootElm); + Assert.Single(timelineTweets); + } + + [Fact] + public void ExtractTimelineTweets_Conversation_Test() + { + var rootElm = this.LoadResponseDocument("ListLatestTweetsTimeline_Conversation.json"); + var timelineTweets = TimelineTweet.ExtractTimelineTweets(rootElm); + Assert.Equal(3, timelineTweets.Length); + } + + [Fact] + public void ToStatus_WithTwitterPostFactory_SimpleTweet_Test() + { + var rootElm = this.LoadResponseDocument("TimelineTweet_SimpleTweet.json"); + var timelineTweet = new TimelineTweet(rootElm); + var status = timelineTweet.ToTwitterStatus(); + var postFactory = new TwitterPostFactory(this.CreateTabInfo()); + var post = postFactory.CreateFromStatus(status, selfUserId: 1L, new HashSet()); + + Assert.Equal("1613784711020826626", post.StatusId.Id); + Assert.Equal(40480664L, post.UserId); + } + + [Fact] + public void ToStatus_WithTwitterPostFactory_TweetWithMedia_Test() + { + var rootElm = this.LoadResponseDocument("TimelineTweet_TweetWithMedia.json"); + var timelineTweet = new TimelineTweet(rootElm); + var status = timelineTweet.ToTwitterStatus(); + var postFactory = new TwitterPostFactory(this.CreateTabInfo()); + var post = postFactory.CreateFromStatus(status, selfUserId: 1L, new HashSet()); + + Assert.Equal("1614587968567783424", post.StatusId.Id); + Assert.Equal(40480664L, post.UserId); + Assert.Equal(2, post.Media.Count); + Assert.Equal("https://pbs.twimg.com/media/FmgrJiEaAAEU42G.png", post.Media[0].Url); + Assert.Equal("OpenTweenで @opentween のツイート一覧を表示しているスクショ", post.Media[0].AltText); + Assert.Equal("https://pbs.twimg.com/media/FmgrJiXaMAEu873.jpg", post.Media[1].Url); + Assert.Equal("OpenTweenの新しい画像投稿画面を動かしている様子のスクショ", post.Media[1].AltText); + } + + [Fact] + public void ToStatus_WithTwitterPostFactory_RetweetedTweet_Test() + { + var rootElm = this.LoadResponseDocument("TimelineTweet_RetweetedTweet.json"); + var timelineTweet = new TimelineTweet(rootElm); + var status = timelineTweet.ToTwitterStatus(); + var postFactory = new TwitterPostFactory(this.CreateTabInfo()); + var post = postFactory.CreateFromStatus(status, selfUserId: 1L, new HashSet()); + + Assert.Equal("1617128268548964354", post.StatusId.Id); + Assert.Equal(40480664L, post.RetweetedByUserId); + Assert.Equal("1617126084138659840", post.RetweetedId!.Id); + Assert.Equal(514241801L, post.UserId); + } + } +} diff --git a/OpenTween.Tests/Api/ImgurApiTest.cs b/OpenTween.Tests/Api/ImgurApiTest.cs index de9a13995..0114f3cb4 100644 --- a/OpenTween.Tests/Api/ImgurApiTest.cs +++ b/OpenTween.Tests/Api/ImgurApiTest.cs @@ -48,37 +48,39 @@ public async Task UploadFileAsync_Test() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(@" - - aaaaaaa - てすと - - 1234567890 - image/png - false - 300 - 300 - 1000 - 0 - 0 - - false - -
- - 0 - false - false - false - - 0 - - 0 - false - aaaaaaaaaaaaaaa - - https://i.imgur.com/aaaaaaa.png -"), + Content = new StringContent(""" + + + aaaaaaa + てすと + + 1234567890 + image/png + false + 300 + 300 + 1000 + 0 + 0 + + false + +
+ + 0 + false + false + false + + 0 + + 0 + false + aaaaaaaaaaaaaaa + + https://i.imgur.com/aaaaaaa.png + + """), }; }); @@ -104,12 +106,14 @@ public async Task UploadFileAsync_ErrorResponseTest() return new HttpResponseMessage(HttpStatusCode.BadRequest) { - Content = new StringContent(@" - - No image data was sent to the upload api - /3/image.xml - POST -"), + Content = new StringContent(""" + + + No image data was sent to the upload api + /3/image.xml + POST + + """), }; }); diff --git a/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs b/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs index 2812623a3..4587f02f5 100644 --- a/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs +++ b/OpenTween.Tests/Api/MicrosoftTranslatorApiTest.cs @@ -73,16 +73,18 @@ public async Task TranslateAsync_Test() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(@"[ - { - ""translations"": [ - { - ""text"": ""ほげほげ"", - ""to"": ""ja"" - } - ] - } -]"), + Content = new StringContent(""" + [ + { + "translations": [ + { + "text": "ほげほげ", + "to": "ja" + } + ] + } + ] + """), }; }); diff --git a/OpenTween.Tests/Api/MobypictureApiTest.cs b/OpenTween.Tests/Api/MobypictureApiTest.cs index 42f45a490..dc0899dc7 100644 --- a/OpenTween.Tests/Api/MobypictureApiTest.cs +++ b/OpenTween.Tests/Api/MobypictureApiTest.cs @@ -45,12 +45,14 @@ public async Task UploadFileAsync_Test() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(@" - - - https://www.mobypicture.com/user/OpenTween/view/00000000 - -"), + Content = new StringContent(""" + + + + https://www.mobypicture.com/user/OpenTween/view/00000000 + + + """), }; }); diff --git a/OpenTween.Tests/Api/TwitterApiStatusTest.cs b/OpenTween.Tests/Api/TwitterApiStatusTest.cs index 187c01568..fcd982715 100644 --- a/OpenTween.Tests/Api/TwitterApiStatusTest.cs +++ b/OpenTween.Tests/Api/TwitterApiStatusTest.cs @@ -244,7 +244,7 @@ public void UpdateFromJsonTest() { var status = new TwitterApiStatus(); - var json = "{\"resources\":{\"statuses\":{\"/statuses/home_timeline\":{\"limit\":150,\"remaining\":100,\"reset\":1356998400}}}}"; + var json = """{"resources":{"statuses":{"/statuses/home_timeline":{"limit":150,"remaining":100,"reset":1356998400}}}}"""; Assert.Raises( x => status.AccessLimitUpdated += x, diff --git a/OpenTween.Tests/Api/TwitterApiTest.cs b/OpenTween.Tests/Api/TwitterApiTest.cs index 85ea02e00..202d529da 100644 --- a/OpenTween.Tests/Api/TwitterApiTest.cs +++ b/OpenTween.Tests/Api/TwitterApiTest.cs @@ -191,7 +191,7 @@ public async Task StatusesShow_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.StatusesShow(statusId: 100L) + await twitterApi.StatusesShow(statusId: new("100")) .ConfigureAwait(false); mock.VerifyAll(); @@ -252,7 +252,7 @@ public async Task StatusesUpdate_Test() await twitterApi.StatusesUpdate( "hogehoge", - replyToId: 100L, + replyToId: new("100"), mediaIds: new[] { 10L, 20L }, autoPopulateReplyMetadata: true, excludeReplyUserIds: new[] { 100L, 200L }, @@ -306,7 +306,7 @@ public async Task StatusesDestroy_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.StatusesDestroy(statusId: 100L) + await twitterApi.StatusesDestroy(statusId: new("100")) .IgnoreResponse() .ConfigureAwait(false); @@ -333,7 +333,7 @@ public async Task StatusesRetweet_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.StatusesRetweet(100L) + await twitterApi.StatusesRetweet(new("100")) .IgnoreResponse() .ConfigureAwait(false); @@ -700,25 +700,27 @@ await twitterApi.DirectMessagesEventsList(count: 50, cursor: "12345abcdefg") public async Task DirectMessagesEventsNew_Test() { var mock = new Mock(); - var responseText = @"{ - ""event"": { - ""type"": ""message_create"", - ""message_create"": { - ""target"": { - ""recipient_id"": ""12345"" - }, - ""message_data"": { - ""text"": ""hogehoge"", - ""attachment"": { - ""type"": ""media"", - ""media"": { - ""id"": ""67890"" - } - } - } - } - } -}"; + var responseText = """ + { + "event": { + "type": "message_create", + "message_create": { + "target": { + "recipient_id": "12345" + }, + "message_data": { + "text": "hogehoge", + "attachment": { + "type": "media", + "media": { + "id": "67890" + } + } + } + } + } + } + """; mock.Setup(x => x.PostJsonAsync( new Uri("direct_messages/events/new.json", UriKind.Relative), @@ -748,7 +750,7 @@ public async Task DirectMessagesEventsDestroy_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.DirectMessagesEventsDestroy(eventId: "100") + await twitterApi.DirectMessagesEventsDestroy(eventId: new("100")) .ConfigureAwait(false); mock.VerifyAll(); @@ -880,7 +882,7 @@ public async Task FavoritesCreate_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.FavoritesCreate(statusId: 100L) + await twitterApi.FavoritesCreate(statusId: new("100")) .IgnoreResponse() .ConfigureAwait(false); @@ -905,7 +907,7 @@ public async Task FavoritesDestroy_Test() using var twitterApi = new TwitterApi(ApiKey.Create("fake_consumer_key"), ApiKey.Create("fake_consumer_secret")); twitterApi.ApiConnection = mock.Object; - await twitterApi.FavoritesDestroy(statusId: 100L) + await twitterApi.FavoritesDestroy(statusId: new("100")) .IgnoreResponse() .ConfigureAwait(false); @@ -1355,7 +1357,7 @@ public async Task MediaMetadataCreate_Test() mock.Setup(x => x.PostJsonAsync( new Uri("https://upload.twitter.com/1.1/media/metadata/create.json", UriKind.Absolute), - "{\"media_id\": \"12345\", \"alt_text\": {\"text\": \"hogehoge\"}}") + """{"media_id": "12345", "alt_text": {"text": "hogehoge"}}""") ) .Returns(Task.CompletedTask); diff --git a/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs b/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs index 8f840e8e1..05b1fa53d 100644 --- a/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs +++ b/OpenTween.Tests/Connection/TwitterApiConnectionTest.cs @@ -209,7 +209,7 @@ public async Task GetAsync_ErrorJsonTest() { return new HttpResponseMessage(HttpStatusCode.Forbidden) { - Content = new StringContent("{\"errors\":[{\"code\":187,\"message\":\"Status is a duplicate.\"}]}"), + Content = new StringContent("""{"errors":[{"code":187,"message":"Status is a duplicate."}]}"""), }; }); @@ -452,14 +452,14 @@ public async Task PostJsonAsync_Test() var body = await x.Content.ReadAsStringAsync() .ConfigureAwait(false); - Assert.Equal("{\"aaaa\": 1111}", body); + Assert.Equal("""{"aaaa": 1111}""", body); return new HttpResponseMessage(HttpStatusCode.NoContent); }); var endpoint = new Uri("hoge/tetete.json", UriKind.Relative); - await apiConnection.PostJsonAsync(endpoint, "{\"aaaa\": 1111}") + await apiConnection.PostJsonAsync(endpoint, """{"aaaa": 1111}""") .ConfigureAwait(false); Assert.Equal(0, mockHandler.QueueCount); @@ -484,7 +484,7 @@ public async Task PostJsonAsync_T_Test() var body = await x.Content.ReadAsStringAsync() .ConfigureAwait(false); - Assert.Equal("{\"aaaa\": 1111}", body); + Assert.Equal("""{"aaaa": 1111}""", body); return new HttpResponseMessage(HttpStatusCode.OK) { @@ -494,7 +494,7 @@ public async Task PostJsonAsync_T_Test() var endpoint = new Uri("hoge/tetete.json", UriKind.Relative); - var response = await apiConnection.PostJsonAsync(endpoint, "{\"aaaa\": 1111}") + var response = await apiConnection.PostJsonAsync(endpoint, """{"aaaa": 1111}""") .ConfigureAwait(false); var result = await response.LoadJsonAsync() diff --git a/OpenTween.Tests/Connection/TwitterComCookieHandlerTest.cs b/OpenTween.Tests/Connection/TwitterComCookieHandlerTest.cs index 822fa5814..994baf6ba 100644 --- a/OpenTween.Tests/Connection/TwitterComCookieHandlerTest.cs +++ b/OpenTween.Tests/Connection/TwitterComCookieHandlerTest.cs @@ -37,7 +37,7 @@ public class TwitterComCookieHandlerTest [Fact] public void ParseCookie_Test() { - var cookie = "guest_id_marketing=hoge; guest_id_ads=hoge; personalization_id=\"hoge\"; guest_id=hoge; ct0=aaaaaaaaaa; kdt=hoge; twid=hoge; auth_token=bbbbbbbbbb; dnt=1"; + var cookie = """guest_id_marketing=hoge; guest_id_ads=hoge; personalization_id="hoge"; guest_id=hoge; ct0=aaaaaaaaaa; kdt=hoge; twid=hoge; auth_token=bbbbbbbbbb; dnt=1"""; var innerHandler = Mock.Of(); using var handler = new TwitterComCookieHandler(innerHandler, cookie); Assert.Equal("aaaaaaaaaa", handler.CsrfToken); diff --git a/OpenTween.Tests/DateTimeUtcTest.cs b/OpenTween.Tests/DateTimeUtcTest.cs index 8f9d9f167..1126c3269 100644 --- a/OpenTween.Tests/DateTimeUtcTest.cs +++ b/OpenTween.Tests/DateTimeUtcTest.cs @@ -269,6 +269,15 @@ public void FromUnixTime_Test() utc.ToDateTimeUnsafe()); } + [Fact] + public void FromUnixTimeMilliseconds_Test() + { + var utc = DateTimeUtc.FromUnixTimeMilliseconds(1234567890123); + + Assert.Equal(new DateTime(2009, 2, 13, 23, 31, 30, 123, DateTimeKind.Utc), + utc.ToDateTimeUnsafe()); + } + public static readonly TheoryData ParseTestFixtures = new() { { "2018-05-06T11:22:33.111", new DateTimeUtc(2018, 5, 6, 11, 22, 33, 111) }, diff --git a/OpenTween.Tests/MediaSelectorPanelTest.cs b/OpenTween.Tests/MediaSelectorPanelTest.cs new file mode 100644 index 000000000..d2bd343c7 --- /dev/null +++ b/OpenTween.Tests/MediaSelectorPanelTest.cs @@ -0,0 +1,41 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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 System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace OpenTween +{ + public class MediaSelectorPanelTest + { + [WinFormsFact] + public void Initialize_Test() + { + using (new MediaSelectorPanel()) + { + } + } + } +} diff --git a/OpenTween.Tests/MediaSelectorTest.cs b/OpenTween.Tests/MediaSelectorTest.cs index 9436d3f19..2ad1e83f3 100644 --- a/OpenTween.Tests/MediaSelectorTest.cs +++ b/OpenTween.Tests/MediaSelectorTest.cs @@ -48,6 +48,25 @@ private void MyCommonSetup() MyCommon.EntryAssembly = mockAssembly.Object; } + [Fact] + public void SelectedMediaServiceIndex_Test() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + + Assert.Equal("Twitter", mediaSelector.MediaServices[0].Key); + Assert.Equal("Imgur", mediaSelector.MediaServices[1].Key); + + mediaSelector.SelectedMediaServiceName = "Imgur"; + Assert.Equal(1, mediaSelector.SelectedMediaServiceIndex); + + mediaSelector.SelectedMediaServiceName = "Twitter"; + Assert.Equal(0, mediaSelector.SelectedMediaServiceIndex); + } + [Fact] public void SelectMediaService_TwitterTest() { @@ -295,6 +314,103 @@ public void SetSelectedMediaAltText_Test() Assert.Equal("Page 2", mediaSelector.MediaItems[1].AltText); } + [Fact] + public void Validate_PassTest() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + mediaSelector.SelectMediaService("Twitter"); + + using var mediaItem = TestUtils.CreateDummyMediaItem(); + mediaSelector.AddMediaItem(mediaItem); + Assert.Equal(MediaSelectorErrorType.None, mediaSelector.Validate(out var rejected)); + Assert.Null(rejected); + } + + [Fact] + public void Validate_EmptyErrorTest() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + mediaSelector.SelectMediaService("Twitter"); + + Assert.Equal( + MediaSelectorErrorType.MediaItemNotSet, + mediaSelector.Validate(out var rejected) + ); + Assert.Null(rejected); + } + + [Fact] + public void Validate_ServiceNotSelectedErrorTest() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + + using var mediaItem = TestUtils.CreateDummyMediaItem(); + mediaSelector.AddMediaItem(mediaItem); + Assert.Equal( + MediaSelectorErrorType.ServiceNotSelected, + mediaSelector.Validate(out var rejected) + ); + Assert.Null(rejected); + } + + [Fact] + public void Validate_ExtensionErrorTest() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + mediaSelector.SelectMediaService("Twitter"); + + var mock = new Mock(); + mock.Setup(x => x.CreateImage()).Returns(() => TestUtils.CreateDummyImage()); + mock.Setup(x => x.Extension).Returns(".exe"); + mock.Setup(x => x.Size).Returns(1_000_000); + + mediaSelector.AddMediaItem(mock.Object); + Assert.Equal( + MediaSelectorErrorType.UnsupportedFileExtension, + mediaSelector.Validate(out var rejected) + ); + Assert.Same(mock.Object, rejected); + } + + [Fact] + public void Validate_FileSizeErrorTest() + { + using var twitterApi = new TwitterApi(ApiKey.Create(""), ApiKey.Create("")); + using var twitter = new Twitter(twitterApi); + using var mediaSelector = new MediaSelector(); + twitter.Initialize("", "", "", 0L); + mediaSelector.InitializeServices(twitter, TwitterConfiguration.DefaultConfiguration()); + mediaSelector.SelectMediaService("Twitter"); + + var mock = new Mock(); + mock.Setup(x => x.CreateImage()).Returns(() => TestUtils.CreateDummyImage()); + mock.Setup(x => x.Extension).Returns(".png"); + mock.Setup(x => x.Size).Returns(1_000_000_000); // 1GB + + mediaSelector.AddMediaItem(mock.Object); + Assert.Equal( + MediaSelectorErrorType.FileSizeExceeded, + mediaSelector.Validate(out var rejected) + ); + Assert.Same(mock.Object, rejected); + } + [Fact] public void MoveSelectedMediaItemToPrevious_Test() { diff --git a/OpenTween.Tests/Models/PostClassTest.cs b/OpenTween.Tests/Models/PostClassTest.cs index eebcf63e4..f4f32a7c6 100644 --- a/OpenTween.Tests/Models/PostClassTest.cs +++ b/OpenTween.Tests/Models/PostClassTest.cs @@ -32,60 +32,6 @@ namespace OpenTween.Models { public class PostClassTest { - private class PostClassGroup - { - private readonly Dictionary testCases; - - public PostClassGroup(params TestPostClass[] postClasses) - { - this.testCases = new Dictionary(); - foreach (var p in postClasses) - { - p.Group = this; - this.testCases.Add(p.StatusId, p); - } - } - - public PostClass this[long id] => this.testCases[id]; - } - - private class TestPostClass : PostClass - { - public PostClassGroup? Group; - - protected override PostClass RetweetSource - { - get - { - var retweetedId = this.RetweetedId!.Value; - var group = this.Group; - if (group == null) - throw new InvalidOperationException("TestPostClass needs group"); - - return group[retweetedId]; - } - } - } - - private readonly PostClassGroup postGroup; - - public PostClassTest() - { - this.postGroup = new PostClassGroup( - new TestPostClass { StatusId = 1L }, - new TestPostClass { StatusId = 2L, IsFav = true }, - new TestPostClass { StatusId = 3L, IsFav = false, RetweetedId = 2L }); - } - - [Fact] - public void CloneTest() - { - var post = new PostClass(); - var clonePost = post.Clone(); - - TestUtils.CheckDeepCloning(post, clonePost); - } - [Theory] [InlineData("", "")] [InlineData("aaa\nbbb", "aaa bbb")] @@ -96,29 +42,6 @@ public void TextSingleLineTest(string text, string expected) Assert.Equal(expected, post.TextSingleLine); } - [Theory] - [InlineData(1L, false)] - [InlineData(2L, true)] - [InlineData(3L, true)] - public void GetIsFavTest(long statusId, bool expected) - => Assert.Equal(expected, this.postGroup[statusId].IsFav); - - [Theory] - [InlineData(2L, true)] - [InlineData(2L, false)] - [InlineData(3L, true)] - [InlineData(3L, false)] - public void SetIsFavTest(long statusId, bool isFav) - { - var post = this.postGroup[statusId]; - - post.IsFav = isFav; - Assert.Equal(isFav, post.IsFav); - - if (post.RetweetedId != null) - Assert.Equal(isFav, this.postGroup[post.RetweetedId.Value].IsFav); - } - #pragma warning disable SA1008 // Opening parenthesis should be spaced correctly [Theory] [InlineData(false, false, false, false, -0x01)] @@ -140,11 +63,11 @@ public void SetIsFavTest(long statusId, bool isFav) #pragma warning restore SA1008 public void StateIndexTest(bool protect, bool mark, bool reply, bool geo, int expected) { - var post = new TestPostClass + var post = new PostClass { IsProtect = protect, IsMark = mark, - InReplyToStatusId = reply ? (long?)100L : null, + InReplyToStatusId = reply ? new TwitterStatusId("100") : null, PostGeo = geo ? new PostClass.StatusGeo(-126.716667, -47.15) : (PostClass.StatusGeo?)null, }; @@ -154,19 +77,19 @@ public void StateIndexTest(bool protect, bool mark, bool reply, bool geo, int ex [Fact] public void SourceHtml_Test() { - var post = new TestPostClass + var post = new PostClass { Source = "Twitter Web Client", SourceUri = new Uri("http://twitter.com/"), }; - Assert.Equal("Twitter Web Client", post.SourceHtml); + Assert.Equal("""Twitter Web Client""", post.SourceHtml); } [Fact] public void SourceHtml_PlainTextTest() { - var post = new TestPostClass + var post = new PostClass { Source = "web", SourceUri = null, @@ -178,19 +101,19 @@ public void SourceHtml_PlainTextTest() [Fact] public void SourceHtml_EscapeTest() { - var post = new TestPostClass + var post = new PostClass { Source = "", SourceUri = new Uri("http://example.com/?aaa=123&bbb=456"), }; - Assert.Equal("<script>alert(1)</script>", post.SourceHtml); + Assert.Equal("""<script>alert(1)</script>""", post.SourceHtml); } [Fact] public void SourceHtml_EscapePlainTextTest() { - var post = new TestPostClass + var post = new PostClass { Source = "", SourceUri = null, @@ -199,32 +122,10 @@ public void SourceHtml_EscapePlainTextTest() Assert.Equal("<script>alert(1)</script>", post.SourceHtml); } - [Fact] - public void DeleteTest() - { - var post = new TestPostClass - { - InReplyToStatusId = 10L, - InReplyToUser = "hogehoge", - InReplyToUserId = 100L, - IsReply = true, - ReplyToList = { (100L, "hogehoge") }, - }; - - post.IsDeleted = true; - - Assert.Null(post.InReplyToStatusId); - Assert.Equal("", post.InReplyToUser); - Assert.Null(post.InReplyToUserId); - Assert.False(post.IsReply); - Assert.Empty(post.ReplyToList); - Assert.Equal(-1, post.StateIndex); - } - [Fact] public void CanDeleteBy_SentDMTest() { - var post = new TestPostClass + var post = new PostClass { IsDm = true, IsMe = true, // 自分が送信した DM @@ -237,7 +138,7 @@ public void CanDeleteBy_SentDMTest() [Fact] public void CanDeleteBy_ReceivedDMTest() { - var post = new TestPostClass + var post = new PostClass { IsDm = true, IsMe = false, // 自分が受け取った DM @@ -250,7 +151,7 @@ public void CanDeleteBy_ReceivedDMTest() [Fact] public void CanDeleteBy_MyTweetTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 111L, // 自分のツイート }; @@ -261,7 +162,7 @@ public void CanDeleteBy_MyTweetTest() [Fact] public void CanDeleteBy_OthersTweetTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 222L, // 他人のツイート }; @@ -272,7 +173,7 @@ public void CanDeleteBy_OthersTweetTest() [Fact] public void CanDeleteBy_RetweetedByMeTest() { - var post = new TestPostClass + var post = new PostClass { RetweetedByUserId = 111L, // 自分がリツイートした UserId = 222L, // 他人のツイート @@ -284,7 +185,7 @@ public void CanDeleteBy_RetweetedByMeTest() [Fact] public void CanDeleteBy_RetweetedByOthersTest() { - var post = new TestPostClass + var post = new PostClass { RetweetedByUserId = 333L, // 他人がリツイートした UserId = 222L, // 他人のツイート @@ -296,7 +197,7 @@ public void CanDeleteBy_RetweetedByOthersTest() [Fact] public void CanDeleteBy_MyTweetHaveBeenRetweetedByOthersTest() { - var post = new TestPostClass + var post = new PostClass { RetweetedByUserId = 222L, // 他人がリツイートした UserId = 111L, // 自分のツイート @@ -308,7 +209,7 @@ public void CanDeleteBy_MyTweetHaveBeenRetweetedByOthersTest() [Fact] public void CanRetweetBy_DMTest() { - var post = new TestPostClass + var post = new PostClass { IsDm = true, IsMe = false, // 自分が受け取った DM @@ -321,7 +222,7 @@ public void CanRetweetBy_DMTest() [Fact] public void CanRetweetBy_MyTweetTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 111L, // 自分のツイート }; @@ -332,7 +233,7 @@ public void CanRetweetBy_MyTweetTest() [Fact] public void CanRetweetBy_ProtectedMyTweetTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 111L, // 自分のツイート IsProtect = true, @@ -344,7 +245,7 @@ public void CanRetweetBy_ProtectedMyTweetTest() [Fact] public void CanRetweetBy_OthersTweet_NotProtectedTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 222L, // 他人のツイート IsProtect = false, @@ -356,7 +257,7 @@ public void CanRetweetBy_OthersTweet_NotProtectedTest() [Fact] public void CanRetweetBy_OthersTweet_ProtectedTest() { - var post = new TestPostClass + var post = new PostClass { UserId = 222L, // 他人のツイート IsProtect = true, @@ -370,11 +271,13 @@ public void ConvertToOriginalPost_Test() { var retweetPost = new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 2, 0, 0, 0), + CreatedAt = new(2023, 1, 1, 0, 0, 0), ScreenName = "@aaa", UserId = 1L, - RetweetedId = 50L, + RetweetedId = new TwitterStatusId("50"), RetweetedBy = "@bbb", RetweetedByUserId = 2L, RetweetedCount = 0, @@ -382,7 +285,9 @@ public void ConvertToOriginalPost_Test() var originalPost = retweetPost.ConvertToOriginalPost(); - Assert.Equal(50L, originalPost.StatusId); + Assert.Equal(new TwitterStatusId("50"), originalPost.StatusId); + Assert.Equal(new(2023, 1, 1, 0, 0, 0), originalPost.CreatedAt); + Assert.Equal(new(2023, 1, 1, 0, 0, 0), originalPost.CreatedAtForSorting); Assert.Equal("@aaa", originalPost.ScreenName); Assert.Equal(1L, originalPost.UserId); @@ -396,7 +301,7 @@ public void ConvertToOriginalPost_Test() public void ConvertToOriginalPost_ErrorTest() { // 公式 RT でないツイート - var post = new PostClass { StatusId = 100L, RetweetedId = null }; + var post = new PostClass { StatusId = new TwitterStatusId("100"), RetweetedId = null }; Assert.Throws(() => post.ConvertToOriginalPost()); } @@ -421,7 +326,7 @@ public async Task ExpandedUrls_BasicScenario() var post = new PostClass { - Text = "bit.ly/abcde", + Text = """bit.ly/abcde""", ExpandedUrls = new[] { new FakeExpandedUrlInfo( @@ -445,7 +350,7 @@ public async Task ExpandedUrls_BasicScenario() Assert.Equal("http://bit.ly/abcde", urlInfo.ExpandedUrl); Assert.Equal("http://bit.ly/abcde", post.GetExpandedUrl("http://t.co/aaaaaaa")); Assert.Equal(new[] { "http://bit.ly/abcde" }, post.GetExpandedUrls()); - Assert.Equal("bit.ly/abcde", post.Text); + Assert.Equal("""bit.ly/abcde""", post.Text); // bit.ly 展開後の URL は「http://example.com/abcde」 urlInfo.FakeResult.SetResult("http://example.com/abcde"); @@ -457,7 +362,7 @@ public async Task ExpandedUrls_BasicScenario() Assert.Equal("http://example.com/abcde", urlInfo.ExpandedUrl); Assert.Equal("http://example.com/abcde", post.GetExpandedUrl("http://t.co/aaaaaaa")); Assert.Equal(new[] { "http://example.com/abcde" }, post.GetExpandedUrls()); - Assert.Equal("bit.ly/abcde", post.Text); + Assert.Equal("""bit.ly/abcde""", post.Text); } } } diff --git a/OpenTween.Tests/Models/PostFilterRuleTest.cs b/OpenTween.Tests/Models/PostFilterRuleTest.cs index 5c6846ad7..a83b42210 100644 --- a/OpenTween.Tests/Models/PostFilterRuleTest.cs +++ b/OpenTween.Tests/Models/PostFilterRuleTest.cs @@ -1410,7 +1410,7 @@ public void FilterRt_Test() filter.FilterRt = true; - post = new PostClass { RetweetedBy = "hogehoge", RetweetedId = 123L }; + post = new PostClass { RetweetedBy = "hogehoge", RetweetedId = new TwitterStatusId("123") }; Assert.Equal(MyCommon.HITRESULT.CopyAndMark, filter.ExecFilter(post)); post = new PostClass { }; @@ -1425,7 +1425,7 @@ public void ExFilterRt_Test() filter.ExFilterRt = true; - post = new PostClass { RetweetedBy = "hogehoge", RetweetedId = 123L }; + post = new PostClass { RetweetedBy = "hogehoge", RetweetedId = new TwitterStatusId("123") }; Assert.Equal(MyCommon.HITRESULT.Exclude, filter.ExecFilter(post)); post = new PostClass { }; diff --git a/OpenTween.Tests/Models/PostIdTest.cs b/OpenTween.Tests/Models/PostIdTest.cs new file mode 100644 index 000000000..7ac665c87 --- /dev/null +++ b/OpenTween.Tests/Models/PostIdTest.cs @@ -0,0 +1,119 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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 System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace OpenTween.Models +{ + public class PostIdTest + { + private PostId CreatePostId(string type, string id) + { + var mock = new Mock() { CallBase = true }; + mock.Setup(x => x.IdType).Returns(type); + mock.Setup(x => x.Id).Returns(id); + return mock.Object; + } + + [Fact] + public void CompareTo_Test() + { + var a = this.CreatePostId("mastodon", "200"); + var b = this.CreatePostId("twitter", "100"); + Assert.True(a.CompareTo(b) < 0); + Assert.True(b.CompareTo(a) > 0); + Assert.Equal(0, a.CompareTo(a)); + } + + [Fact] + public void CompareTo_SameIdTypeTest() + { + var a = this.CreatePostId("twitter", "100"); + var b = this.CreatePostId("twitter", "200"); + Assert.True(a.CompareTo(b) < 0); + Assert.True(b.CompareTo(a) > 0); + Assert.Equal(0, a.CompareTo(a)); + } + + [Fact] + public void Equals_Test() + { + var a = this.CreatePostId("twitter", "100"); + var b = this.CreatePostId("twitter", "100"); + Assert.True(a.Equals(b)); + Assert.True(b.Equals(a)); + Assert.True(a == b); + Assert.True(b == a); + } + + [Fact] + public void Equals_NotSameIdTypeTest() + { + var a = this.CreatePostId("mastodon", "100"); + var b = this.CreatePostId("twitter", "100"); + Assert.False(a.Equals(b)); + Assert.False(b.Equals(a)); + Assert.True(a != b); + Assert.True(b != a); + } + + [Fact] + public void Equals_NotSameIdTest() + { + var a = this.CreatePostId("twitter", "100"); + var b = this.CreatePostId("twitter", "200"); + Assert.False(a.Equals(b)); + Assert.False(b.Equals(a)); + Assert.True(a != b); + Assert.True(b != a); + } + + [Fact] + public void GetHashCode_SameIdTest() + { + var a = this.CreatePostId("twitter", "100"); + var b = this.CreatePostId("twitter", "100"); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_NotSameIdTypeTest() + { + var a = this.CreatePostId("mastodon", "100"); + var b = this.CreatePostId("twitter", "100"); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void GetHashCode_NotSameIdTest() + { + var a = this.CreatePostId("twitter", "100"); + var b = this.CreatePostId("twitter", "200"); + Assert.NotEqual(a.GetHashCode(), b.GetHashCode()); + } + } +} diff --git a/OpenTween.Tests/Models/TabInformationTest.cs b/OpenTween.Tests/Models/TabInformationTest.cs index 4545169f6..f4ba637d9 100644 --- a/OpenTween.Tests/Models/TabInformationTest.cs +++ b/OpenTween.Tests/Models/TabInformationTest.cs @@ -24,6 +24,7 @@ using System.Linq; using System.Reflection; using System.Text; +using System.Windows.Forms; using Xunit; using Xunit.Extensions; @@ -70,6 +71,157 @@ public void AddTab_DuplicateTest() Assert.False(ret); } + [Fact] + public void RemoveTab_InnerStorageTabTest() + { + var tab = new PublicSearchTabModel("search"); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); + this.tabinfo.AddTab(tab); + this.tabinfo.SubmitUpdate(); + + Assert.True(this.tabinfo.ContainsTab("search")); + Assert.Empty(this.tabinfo.RemovedTab); + + this.tabinfo.RemoveTab("search"); + + Assert.False(this.tabinfo.ContainsTab("search")); + Assert.Single(this.tabinfo.RemovedTab); + Assert.Contains(tab, this.tabinfo.RemovedTab); + } + + [Fact] + public void RemoveTab_FilterTab_MovedPost_OrphanedTest() + { + var filterTab = new FilterTabModel("filter"); + filterTab.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "opentween" }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + Assert.False(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab.Contains(new TwitterStatusId("100"))); + + this.tabinfo.RemoveTab("filter"); + + Assert.False(this.tabinfo.ContainsTab("filter")); + Assert.Single(this.tabinfo.RemovedTab); + Assert.Contains(filterTab, this.tabinfo.RemovedTab); + + // 他に MoveMatches で移動している振り分けタブが存在しなければ Home タブに戻す + Assert.True(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void RemoveTab_FilterTab_MovedPost_NotOrphanedTest() + { + var filterTab1 = new FilterTabModel("filter1"); + filterTab1.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab1); + + var filterTab2 = new FilterTabModel("filter2"); + filterTab2.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab2); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "opentween" }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + Assert.False(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab1.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab2.Contains(new TwitterStatusId("100"))); + + this.tabinfo.RemoveTab("filter1"); + + Assert.False(this.tabinfo.ContainsTab("filter1")); + Assert.Single(this.tabinfo.RemovedTab); + Assert.Contains(filterTab1, this.tabinfo.RemovedTab); + + // 他に MoveMatches で移動している振り分けタブが存在する場合は Home タブに戻さない + Assert.False(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab2.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void RemoveTab_FilterTab_CopiedPost_Test() + { + var filterTab = new FilterTabModel("filter"); + filterTab.AddFilter(new() { FilterName = "opentween", MoveMatches = false }); + this.tabinfo.AddTab(filterTab); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "opentween" }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + Assert.True(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab.Contains(new TwitterStatusId("100"))); + + this.tabinfo.RemoveTab("filter"); + + Assert.False(this.tabinfo.ContainsTab("filter")); + Assert.Single(this.tabinfo.RemovedTab); + Assert.Contains(filterTab, this.tabinfo.RemovedTab); + + // 振り分けタブにコピーされた発言は Home タブにも存在しているため何もしない + Assert.True(this.tabinfo.HomeTab.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void CanUndoRemovedTab_Test() + { + var tab = new PublicSearchTabModel("tab"); + this.tabinfo.AddTab(tab); + Assert.False(this.tabinfo.CanUndoRemovedTab); + + this.tabinfo.RemoveTab(tab.TabName); + Assert.True(this.tabinfo.CanUndoRemovedTab); + } + + [Fact] + public void UndoRemovedTab_Test() + { + var tab = new PublicSearchTabModel("tab"); + this.tabinfo.AddTab(tab); + Assert.True(this.tabinfo.ContainsTab("tab")); + Assert.Empty(this.tabinfo.RemovedTab); + + this.tabinfo.RemoveTab("tab"); + Assert.False(this.tabinfo.ContainsTab("tab")); + Assert.Single(this.tabinfo.RemovedTab); + + var restoredTab = this.tabinfo.UndoRemovedTab(); + Assert.True(this.tabinfo.ContainsTab("tab")); + Assert.Same(tab, restoredTab); + Assert.Empty(this.tabinfo.RemovedTab); + } + + [Fact] + public void UndoRemovedTab_EmptyError_Test() + { + Assert.Empty(this.tabinfo.RemovedTab); + Assert.Throws( + () => this.tabinfo.UndoRemovedTab() + ); + } + + [Fact] + public void UndoRemovedTab_DuplicatedName_Test() + { + var tab = new PublicSearchTabModel("tab"); + this.tabinfo.AddTab(tab); + Assert.Empty(this.tabinfo.RemovedTab); + + this.tabinfo.RemoveTab("tab"); + Assert.Single(this.tabinfo.RemovedTab); + + this.tabinfo.RenameTab("Recent", "tab"); + Assert.Throws( + () => this.tabinfo.UndoRemovedTab() + ); + Assert.Single(this.tabinfo.RemovedTab); + } + [Fact] public void RenameTab_PositionTest() { @@ -95,6 +247,56 @@ public void RenameTab_SelectedTabTest() Assert.Equal(replyTab, this.tabinfo.SelectedTab); } + [Fact] + public void MoveTab_MoveToStart_Test() + { + Assert.Equal(0, this.tabinfo.Tabs.IndexOf("Recent")); + Assert.Equal(1, this.tabinfo.Tabs.IndexOf("Reply")); + + this.tabinfo.MoveTab(0, this.tabinfo.MentionTab); + + Assert.Equal(0, this.tabinfo.Tabs.IndexOf("Reply")); + Assert.Equal(1, this.tabinfo.Tabs.IndexOf("Recent")); + } + + [Fact] + public void MoveTab_MoveToEnd_Test() + { + Assert.Equal(4, this.tabinfo.Tabs.Count); + Assert.Equal(2, this.tabinfo.Tabs.IndexOf("DM")); + Assert.Equal(3, this.tabinfo.Tabs.IndexOf("Favorites")); + + this.tabinfo.MoveTab(3, this.tabinfo.DirectMessageTab); + + Assert.Equal(2, this.tabinfo.Tabs.IndexOf("Favorites")); + Assert.Equal(3, this.tabinfo.Tabs.IndexOf("DM")); + } + + [Fact] + public void MoveTab_OutOfRangeError_Test() + { + Assert.Equal(4, this.tabinfo.Tabs.Count); + Assert.Throws( + () => this.tabinfo.MoveTab(-1, this.tabinfo.HomeTab) + ); + Assert.Throws( + () => this.tabinfo.MoveTab(4, this.tabinfo.HomeTab) + ); + } + + [Theory] + [InlineData("Reply", true)] + [InlineData("UNKNOWN NAME", false)] + public void ContainsTab_TabName_Test(string tabName, bool expected) + => Assert.Equal(expected, this.tabinfo.ContainsTab(tabName)); + + [Fact] + public void ContainsTab_TabInstance_Test() + { + Assert.True(this.tabinfo.ContainsTab(this.tabinfo.HomeTab)); + Assert.False(this.tabinfo.ContainsTab(new PublicSearchTabModel("tab"))); + } + [Fact] public void SelectTab_Test() { @@ -226,6 +428,50 @@ public void MakeTabName_RetryErrorTest() Assert.Throws(() => this.tabinfo.MakeTabName(baseTabName, 5)); } + [Fact] + public void SetSortMode_Test() + { + this.tabinfo.SetSortMode(ComparerMode.Id, SortOrder.Descending); + Assert.Equal(ComparerMode.Id, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Descending, this.tabinfo.SortOrder); + Assert.Equal(ComparerMode.Id, this.tabinfo.HomeTab.SortMode); + Assert.Equal(SortOrder.Descending, this.tabinfo.HomeTab.SortOrder); + + this.tabinfo.SetSortMode(ComparerMode.Source, SortOrder.Ascending); + Assert.Equal(ComparerMode.Source, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Ascending, this.tabinfo.SortOrder); + Assert.Equal(ComparerMode.Source, this.tabinfo.HomeTab.SortMode); + Assert.Equal(SortOrder.Ascending, this.tabinfo.HomeTab.SortOrder); + } + + [Fact] + public void ToggleSortOrder_SameMode_Test() + { + this.tabinfo.SetSortMode(ComparerMode.Id, SortOrder.Descending); + Assert.Equal(ComparerMode.Id, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Descending, this.tabinfo.SortOrder); + + this.tabinfo.ToggleSortOrder(ComparerMode.Id); + Assert.Equal(ComparerMode.Id, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Ascending, this.tabinfo.SortOrder); + + this.tabinfo.ToggleSortOrder(ComparerMode.Id); + Assert.Equal(ComparerMode.Id, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Descending, this.tabinfo.SortOrder); + } + + [Fact] + public void ToggleSortOrder_OtherMode_Test() + { + this.tabinfo.SetSortMode(ComparerMode.Id, SortOrder.Descending); + Assert.Equal(ComparerMode.Id, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Descending, this.tabinfo.SortOrder); + + this.tabinfo.ToggleSortOrder(ComparerMode.Source); + Assert.Equal(ComparerMode.Source, this.tabinfo.SortMode); + Assert.Equal(SortOrder.Ascending, this.tabinfo.SortOrder); + } + [Fact] public void IsMuted_Test() { @@ -366,15 +612,15 @@ public void SetReadAllTab_MarkAsReadTest() // search1 に追加するツイート (StatusId: 100, 150, 200; すべて未読) tab1.UnreadManage = true; - tab1.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); - tab1.AddPostQueue(new PostClass { StatusId = 150L, IsRead = false }); - tab1.AddPostQueue(new PostClass { StatusId = 200L, IsRead = false }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = false }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = false }); // search2 に追加するツイート (StatusId: 150, 200, 250; すべて未読) tab2.UnreadManage = true; - tab2.AddPostQueue(new PostClass { StatusId = 150L, IsRead = false }); - tab2.AddPostQueue(new PostClass { StatusId = 200L, IsRead = false }); - tab2.AddPostQueue(new PostClass { StatusId = 250L, IsRead = false }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = false }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = false }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("250"), IsRead = false }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); @@ -386,12 +632,12 @@ public void SetReadAllTab_MarkAsReadTest() // ... ここまで長い前置き // StatusId: 200 を既読にする (search1, search2 両方に含まれる) - this.tabinfo.SetReadAllTab(200L, read: true); + this.tabinfo.SetReadAllTab(new TwitterStatusId("200"), read: true); Assert.Equal(2, tab1.UnreadCount); Assert.Equal(2, tab2.UnreadCount); // StatusId: 100 を既読にする (search1 のみに含まれる) - this.tabinfo.SetReadAllTab(100L, read: true); + this.tabinfo.SetReadAllTab(new TwitterStatusId("100"), read: true); Assert.Equal(1, tab1.UnreadCount); Assert.Equal(2, tab2.UnreadCount); } @@ -407,15 +653,15 @@ public void SetReadAllTab_MarkAsUnreadTest() // search1 に追加するツイート (StatusId: 100, 150, 200; すべて既読) tab1.UnreadManage = true; - tab1.AddPostQueue(new PostClass { StatusId = 100L, IsRead = true }); - tab1.AddPostQueue(new PostClass { StatusId = 150L, IsRead = true }); - tab1.AddPostQueue(new PostClass { StatusId = 200L, IsRead = true }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = true }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = true }); + tab1.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = true }); // search2 に追加するツイート (StatusId: 150, 200, 250; すべて既読) tab2.UnreadManage = true; - tab2.AddPostQueue(new PostClass { StatusId = 150L, IsRead = true }); - tab2.AddPostQueue(new PostClass { StatusId = 200L, IsRead = true }); - tab2.AddPostQueue(new PostClass { StatusId = 250L, IsRead = true }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = true }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = true }); + tab2.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("250"), IsRead = true }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); @@ -427,12 +673,12 @@ public void SetReadAllTab_MarkAsUnreadTest() // ... ここまで長い前置き // StatusId: 200 を未読にする (search1, search2 両方に含まれる) - this.tabinfo.SetReadAllTab(200L, read: false); + this.tabinfo.SetReadAllTab(new TwitterStatusId("200"), read: false); Assert.Equal(1, tab1.UnreadCount); Assert.Equal(1, tab2.UnreadCount); // StatusId: 100 を未読にする (search1 のみに含まれる) - this.tabinfo.SetReadAllTab(100L, read: false); + this.tabinfo.SetReadAllTab(new TwitterStatusId("100"), read: false); Assert.Equal(2, tab1.UnreadCount); Assert.Equal(1, tab2.UnreadCount); } @@ -444,9 +690,9 @@ public void SetReadHomeTab_Test() // Recent に追加するツイート (StatusId: 100, 150, 200; すべて未読) homeTab.UnreadManage = true; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsRead = false }); - this.tabinfo.AddPost(new PostClass { StatusId = 150L, IsRead = false }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = false }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); @@ -467,9 +713,9 @@ public void SetReadHomeTab_ContainsReplyTest() // Recent に追加するツイート (StatusId: 100, 150, 200; すべて未読) // StatusId: 150 は未読だがリプライ属性が付いている homeTab.UnreadManage = true; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsRead = false }); - this.tabinfo.AddPost(new PostClass { StatusId = 150L, IsRead = false, IsReply = true }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = false, IsReply = true }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = false }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); @@ -482,7 +728,7 @@ public void SetReadHomeTab_ContainsReplyTest() // リプライである StatusId: 150 を除いてすべて未読になっている Assert.Equal(1, homeTab.UnreadCount); - Assert.Equal(150L, homeTab.NextUnreadId); + Assert.Equal(new TwitterStatusId("150"), homeTab.NextUnreadId); } [Fact] @@ -492,14 +738,14 @@ public void SetReadHomeTab_ContainsFilterHitTest() // Recent に追加するツイート (StatusId: 100, 150, 200; すべて未読) homeTab.UnreadManage = true; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsRead = false }); - this.tabinfo.AddPost(new PostClass { StatusId = 150L, IsRead = false }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("150"), IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = false }); // StatusId: 150 だけ FilterTab の振り分けルールにヒットする (PostClass.FilterHit が true になる) var filterTab = new FilterTabModel("FilterTab"); filterTab.AddFilter(TestPostFilterRule.Create(x => - x.StatusId == 150L ? MyCommon.HITRESULT.Copy : MyCommon.HITRESULT.None)); + x.StatusId == new TwitterStatusId("150") ? MyCommon.HITRESULT.Copy : MyCommon.HITRESULT.None)); this.tabinfo.AddTab(filterTab); this.tabinfo.DistributePosts(); @@ -513,7 +759,7 @@ public void SetReadHomeTab_ContainsFilterHitTest() // FilterHit が true である StatusId: 150 を除いてすべて未読になっている Assert.Equal(1, homeTab.UnreadCount); - Assert.Equal(150L, homeTab.NextUnreadId); + Assert.Equal(new TwitterStatusId("150"), homeTab.NextUnreadId); } [Fact] @@ -521,13 +767,13 @@ public void SubmitUpdate_RemoveSubmit_Test() { var homeTab = this.tabinfo.HomeTab; - this.tabinfo.AddPost(new PostClass { StatusId = 100L }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100") }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); Assert.Equal(1, homeTab.AllCount); - this.tabinfo.RemovePostFromAllTabs(100L, setIsDeleted: true); + this.tabinfo.RemovePostFromAllTabs(new TwitterStatusId("100"), setIsDeleted: true); // この時点ではまだ削除されない Assert.Equal(1, homeTab.AllCount); @@ -536,7 +782,7 @@ public void SubmitUpdate_RemoveSubmit_Test() Assert.True(isDeletePost); Assert.Equal(0, homeTab.AllCount); - Assert.False(this.tabinfo.Posts.ContainsKey(100L)); + Assert.False(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); } [Fact] @@ -545,7 +791,7 @@ public void SubmitUpdate_RemoveSubmit_NotOrphaned_Test() var homeTab = this.tabinfo.HomeTab; var favTab = this.tabinfo.FavoriteTab; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsFav = true }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsFav = true }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); @@ -553,7 +799,7 @@ public void SubmitUpdate_RemoveSubmit_NotOrphaned_Test() Assert.Equal(1, favTab.AllCount); // favTab のみ発言を除去 (homeTab には残ったまま) - favTab.EnqueueRemovePost(100L, setIsDeleted: false); + favTab.EnqueueRemovePost(new TwitterStatusId("100"), setIsDeleted: false); // この時点ではまだ削除されない Assert.Equal(1, homeTab.AllCount); @@ -566,7 +812,7 @@ public void SubmitUpdate_RemoveSubmit_NotOrphaned_Test() Assert.Equal(0, favTab.AllCount); // homeTab には発言が残っているので Posts からは削除されない - Assert.True(this.tabinfo.Posts.ContainsKey(100L)); + Assert.True(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); } [Fact] @@ -588,13 +834,13 @@ public void SubmitUpdate_NotifyPriorityTest() dmTab.SoundFile = "dm.wav"; // 通常ツイート - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); // リプライ - this.tabinfo.AddPost(new PostClass { StatusId = 200L, IsReply = true, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), IsReply = true, IsRead = false }); // DM - dmTab.AddPostQueue(new PostClass { StatusId = 300L, IsDm = true, IsRead = false }); + dmTab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("300"), IsDm = true, IsRead = false }); this.tabinfo.DistributePosts(); @@ -621,10 +867,10 @@ public void SubmitUpdate_IgnoreEmptySoundPath_Test() replyTab.SoundFile = ""; // 通常ツイート - this.tabinfo.AddPost(new PostClass { StatusId = 100L, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); // リプライ - this.tabinfo.AddPost(new PostClass { StatusId = 200L, IsReply = true, IsRead = false }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), IsReply = true, IsRead = false }); this.tabinfo.DistributePosts(); @@ -656,15 +902,15 @@ public void FilterAll_CopyFilterTest() myTab1.AddFilter(filter); myTab1.FilterModified = false; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, ScreenName = "aaa" }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, ScreenName = "bbb" }); - this.tabinfo.AddPost(new PostClass { StatusId = 300L, ScreenName = "ccc" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "aaa" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "bbb" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc" }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); // この時点での振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 100L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100") }, myTab1.StatusIds); // フィルタを変更する filter.FilterName = "bbb"; @@ -679,8 +925,8 @@ public void FilterAll_CopyFilterTest() // [statusId: 200] は Recent から MyTab1 にコピーされる // 変更後の振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 200L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("200") }, myTab1.StatusIds); } [Fact] @@ -702,15 +948,15 @@ public void FilterAll_CopyAndMarkFilterTest() myTab1.AddFilter(filter); myTab1.FilterModified = false; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, ScreenName = "aaa" }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, ScreenName = "bbb" }); - this.tabinfo.AddPost(new PostClass { StatusId = 300L, ScreenName = "ccc" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "aaa" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "bbb" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc" }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); // この時点での振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 100L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100") }, myTab1.StatusIds); // フィルタを変更する filter.FilterName = "bbb"; @@ -725,11 +971,11 @@ public void FilterAll_CopyAndMarkFilterTest() // [statusId: 200] は Recent から MyTab1 にコピーされ、マークが付与される // 変更後の振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 200L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("200") }, myTab1.StatusIds); // [statusId: 200] は IsMark が true の状態になる - Assert.True(this.tabinfo[200L]!.IsMark); + Assert.True(this.tabinfo[new TwitterStatusId("200")]!.IsMark); } [Fact] @@ -750,15 +996,15 @@ public void FilterAll_MoveFilterTest() myTab1.AddFilter(filter); myTab1.FilterModified = false; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, ScreenName = "aaa" }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, ScreenName = "bbb" }); - this.tabinfo.AddPost(new PostClass { StatusId = 300L, ScreenName = "ccc" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "aaa" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "bbb" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc" }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); // この時点での振り分け状態 - Assert.Equal(new[] { 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 100L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100") }, myTab1.StatusIds); // フィルタを変更する filter.FilterName = "bbb"; @@ -773,8 +1019,8 @@ public void FilterAll_MoveFilterTest() // [statusId: 200] は Recent から MyTab1 に移動される // 変更後の振り分け状態 - Assert.Equal(new[] { 100L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 200L }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("200") }, myTab1.StatusIds); } [Fact] @@ -807,16 +1053,16 @@ public void FilterAll_MoveFilterTest2() myTab2.AddFilter(filter2); myTab2.FilterModified = false; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, ScreenName = "aaa" }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, ScreenName = "bbb" }); - this.tabinfo.AddPost(new PostClass { StatusId = 300L, ScreenName = "ccc" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "aaa" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "bbb" }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc" }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); // この時点での振り分け状態 - Assert.Equal(new[] { 300L }, homeTab.StatusIds); - Assert.Equal(new[] { 100L }, myTab1.StatusIds); - Assert.Equal(new[] { 200L }, myTab2.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("300") }, homeTab.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100") }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("200") }, myTab2.StatusIds); // MyTab1 のフィルタを変更する filter1.FilterName = "bbb"; @@ -836,9 +1082,9 @@ public void FilterAll_MoveFilterTest2() // [statusId: 300] は Recent から MyTab2 に移動される // 変更後の振り分け状態 - Assert.Equal(new[] { 100L }, homeTab.StatusIds); - Assert.Equal(new[] { 200L }, myTab1.StatusIds); - Assert.Equal(new[] { 300L }, myTab2.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100") }, homeTab.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("200") }, myTab1.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("300") }, myTab2.StatusIds); } [Fact] @@ -855,18 +1101,18 @@ public void FilterAll_ExcludeReplyFilterTest() replyTab.AddFilter(filter); replyTab.FilterModified = false; - this.tabinfo.AddPost(new PostClass { StatusId = 100L, ScreenName = "aaa", IsReply = true }); - this.tabinfo.AddPost(new PostClass { StatusId = 200L, ScreenName = "bbb", IsReply = true }); - this.tabinfo.AddPost(new PostClass { StatusId = 300L, ScreenName = "ccc", IsReply = true }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "aaa", IsReply = true }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "bbb", IsReply = true }); + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc", IsReply = true }); this.tabinfo.DistributePosts(); this.tabinfo.SubmitUpdate(); // この時点での振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 200L, 300L }, replyTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("200"), new TwitterStatusId("300") }, replyTab.StatusIds, AnyOrderComparer.Instance); // [statusId: 100] は IsExcludeReply が true の状態になっている - Assert.True(this.tabinfo[100L]!.IsExcludeReply); + Assert.True(this.tabinfo[new TwitterStatusId("100")]!.IsExcludeReply); // Reply のフィルタを変更する filter.ExFilterName = "bbb"; @@ -881,14 +1127,94 @@ public void FilterAll_ExcludeReplyFilterTest() // [statusId: 200] は Reply から取り除かれ、IsExcludeReply が true になる // 変更後の振り分け状態 - Assert.Equal(new[] { 100L, 200L, 300L }, homeTab.StatusIds, AnyOrderComparer.Instance); - Assert.Equal(new[] { 100L, 300L }, replyTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("200"), new TwitterStatusId("300") }, homeTab.StatusIds, AnyOrderComparer.Instance); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("300") }, replyTab.StatusIds, AnyOrderComparer.Instance); // [statusId: 100] は IsExcludeReply が false の状態になる - Assert.False(this.tabinfo[100L]!.IsExcludeReply); + Assert.False(this.tabinfo[new TwitterStatusId("100")]!.IsExcludeReply); // [statusId: 200] は IsExcludeReply が true の状態になる - Assert.True(this.tabinfo[200L]!.IsExcludeReply); + Assert.True(this.tabinfo[new TwitterStatusId("200")]!.IsExcludeReply); + } + + [Fact] + public void ClearTabIds_InnerStorageTabTest() + { + var tab = new PublicSearchTabModel("search"); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); + this.tabinfo.AddTab(tab); + this.tabinfo.SubmitUpdate(); + + Assert.True(tab.Contains(new TwitterStatusId("100"))); + + this.tabinfo.ClearTabIds("search"); + Assert.False(tab.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void ClearTabIds_FilterTab_MovedPost_OrphanedTest() + { + var filterTab = new FilterTabModel("filter"); + filterTab.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "opentween" }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + Assert.True(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); + Assert.True(filterTab.Contains(new TwitterStatusId("100"))); + + this.tabinfo.ClearTabIds("filter"); + Assert.False(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); + Assert.False(filterTab.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void ClearTabIds_FilterTab_MovedPost_NotOrphanedTest() + { + var filterTab1 = new FilterTabModel("filter1"); + filterTab1.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab1); + + var filterTab2 = new FilterTabModel("filter2"); + filterTab2.AddFilter(new() { FilterName = "opentween", MoveMatches = true }); + this.tabinfo.AddTab(filterTab2); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "opentween" }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + Assert.True(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); + Assert.True(filterTab1.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab2.Contains(new TwitterStatusId("100"))); + + this.tabinfo.ClearTabIds("filter1"); + + // 他に MoveMatches で移動している振り分けタブが存在する場合は TabInformations.Posts から削除しない + Assert.True(this.tabinfo.ContainsKey(new TwitterStatusId("100"))); + Assert.False(filterTab1.Contains(new TwitterStatusId("100"))); + Assert.True(filterTab2.Contains(new TwitterStatusId("100"))); + } + + [Fact] + public void ClearTabIds_NotAffectToOtherTabs_Test() + { + var otherTab = new PublicSearchTabModel("search"); + otherTab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); + this.tabinfo.AddTab(otherTab); + + this.tabinfo.AddPost(new PostClass { StatusId = new TwitterStatusId("100") }); + this.tabinfo.DistributePosts(); + this.tabinfo.SubmitUpdate(); + + // Recent, search のタブに status_id = 100 の発言が存在する状態 + Assert.True(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); + Assert.True(otherTab.Contains(new TwitterStatusId("100"))); + + this.tabinfo.ClearTabIds("Recent"); + Assert.False(this.tabinfo.Posts.ContainsKey(new TwitterStatusId("100"))); + Assert.True(otherTab.Contains(new TwitterStatusId("100"))); } [Fact] @@ -896,7 +1222,7 @@ public void RefreshOwl_HomeTabTest() { var post = new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), ScreenName = "aaa", UserId = 123L, IsOwl = true, @@ -919,7 +1245,7 @@ public void RefreshOwl_InnerStoregeTabTest() var post = new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), ScreenName = "aaa", UserId = 123L, IsOwl = true, @@ -939,7 +1265,7 @@ public void RefreshOwl_UnfollowedTest() { var post = new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), ScreenName = "aaa", UserId = 123L, IsOwl = false, @@ -954,6 +1280,71 @@ public void RefreshOwl_UnfollowedTest() Assert.True(post.IsOwl); } + [Fact] + public void GetTabByType_Generics_Test() + { + var tab = new PublicSearchTabModel("search"); + this.tabinfo.AddTab(tab); + Assert.Same(tab, this.tabinfo.GetTabByType()); + } + + [Fact] + public void GetTabByType_Generics_NotFoundTest() + => Assert.Null(this.tabinfo.GetTabByType()); + + [Fact] + public void GetTabByType_Enum_Test() + { + var tab = new PublicSearchTabModel("search"); + this.tabinfo.AddTab(tab); + Assert.Same(tab, this.tabinfo.GetTabByType(MyCommon.TabUsageType.PublicSearch)); + } + + [Fact] + public void GetTabByType_Enum_NotFoundTest() + => Assert.Null(this.tabinfo.GetTabByType(MyCommon.TabUsageType.PublicSearch)); + + [Fact] + public void GetTabsByType_Generics_Test() + { + var tab1 = new PublicSearchTabModel("search1"); + var tab2 = new PublicSearchTabModel("search2"); + this.tabinfo.AddTab(tab1); + this.tabinfo.AddTab(tab2); + Assert.Equal(new[] { tab1, tab2 }, this.tabinfo.GetTabsByType()); + } + + [Fact] + public void GetTabsByType_Enum_Test() + { + var tab1 = new PublicSearchTabModel("search1"); + var tab2 = new PublicSearchTabModel("search2"); + this.tabinfo.AddTab(tab1); + this.tabinfo.AddTab(tab2); + Assert.Equal(new[] { tab1, tab2 }, this.tabinfo.GetTabsByType(MyCommon.TabUsageType.PublicSearch)); + } + + [Fact] + public void GetTabsInnerStorageType_Test() + { + Assert.Equal( + new TabModel[] { this.tabinfo.DirectMessageTab }, + this.tabinfo.GetTabsInnerStorageType() + ); + } + + [Fact] + public void GetTabByName_Test() + { + var tab = new PublicSearchTabModel("search"); + this.tabinfo.AddTab(tab); + Assert.Same(tab, this.tabinfo.GetTabByName("search")); + } + + [Fact] + public void GetTabByName_NotFoundTest() + => Assert.Null(this.tabinfo.GetTabByName("UNKNOWN_NAME")); + private class TestPostFilterRule : PostFilterRule { public static PostFilterRule Create(Func filterDelegate) diff --git a/OpenTween.Tests/Models/TabModelTest.cs b/OpenTween.Tests/Models/TabModelTest.cs index 2cf139d1a..f256d15aa 100644 --- a/OpenTween.Tests/Models/TabModelTest.cs +++ b/OpenTween.Tests/Models/TabModelTest.cs @@ -39,8 +39,8 @@ public void AnchorPost_Test() var posts = new[] { - new PostClass { StatusId = 100L }, - new PostClass { StatusId = 110L }, + new PostClass { StatusId = new TwitterStatusId("100") }, + new PostClass { StatusId = new TwitterStatusId("110") }, }; tab.AddPostQueue(posts[0]); tab.AddPostQueue(posts[1]); @@ -51,8 +51,8 @@ public void AnchorPost_Test() tab.AnchorPost = posts[1]; - Assert.Equal(110L, tab.AnchorStatusId); - Assert.Equal(110L, tab.AnchorPost.StatusId); + Assert.Equal(new TwitterStatusId("110"), tab.AnchorStatusId); + Assert.Equal(new TwitterStatusId("110"), tab.AnchorPost.StatusId); } [Fact] @@ -62,15 +62,15 @@ public void AnchorPost_DeletedTest() var posts = new[] { - new PostClass { StatusId = 100L }, + new PostClass { StatusId = new TwitterStatusId("100") }, }; tab.AddPostQueue(posts[0]); tab.AddSubmit(); tab.AnchorPost = posts[0]; - Assert.Equal(100L, tab.AnchorPost.StatusId); + Assert.Equal(new TwitterStatusId("100"), tab.AnchorPost.StatusId); - tab.EnqueueRemovePost(100L, setIsDeleted: true); + tab.EnqueueRemovePost(new TwitterStatusId("100"), setIsDeleted: true); tab.RemoveSubmit(); Assert.Null(tab.AnchorPost); @@ -83,13 +83,13 @@ public void ClearAnchor_Test() var posts = new[] { - new PostClass { StatusId = 100L }, + new PostClass { StatusId = new TwitterStatusId("100") }, }; tab.AddPostQueue(posts[0]); tab.AddSubmit(); tab.AnchorPost = posts[0]; - Assert.Equal(100L, tab.AnchorPost.StatusId); + Assert.Equal(new TwitterStatusId("100"), tab.AnchorPost.StatusId); tab.ClearAnchor(); Assert.Null(tab.AnchorPost); } @@ -101,9 +101,9 @@ public void SelectPosts_Test() var posts = new[] { - new PostClass { StatusId = 100L }, - new PostClass { StatusId = 110L }, - new PostClass { StatusId = 120L }, + new PostClass { StatusId = new TwitterStatusId("100") }, + new PostClass { StatusId = new TwitterStatusId("110") }, + new PostClass { StatusId = new TwitterStatusId("120") }, }; tab.AddPostQueue(posts[0]); tab.AddPostQueue(posts[1]); @@ -112,8 +112,8 @@ public void SelectPosts_Test() tab.SelectPosts(new[] { 0, 2 }); - Assert.Equal(new[] { 100L, 120L }, tab.SelectedStatusIds); - Assert.Equal(100L, tab.SelectedStatusId); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("120") }, tab.SelectedStatusIds); + Assert.Equal(new TwitterStatusId("100"), tab.SelectedStatusId); Assert.Equal(new[] { posts[0], posts[2] }, tab.SelectedPosts); Assert.Equal(posts[0], tab.SelectedPost); Assert.Equal(0, tab.SelectedIndex); @@ -123,13 +123,13 @@ public void SelectPosts_Test() public void SelectPosts_EmptyTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); tab.AddSubmit(); tab.SelectPosts(Array.Empty()); Assert.Empty(tab.SelectedStatusIds); - Assert.Equal(-1L, tab.SelectedStatusId); + Assert.Null(tab.SelectedStatusId); Assert.Empty(tab.SelectedPosts); Assert.Null(tab.SelectedPost); Assert.Equal(-1, tab.SelectedIndex); @@ -139,7 +139,7 @@ public void SelectPosts_EmptyTest() public void SelectPosts_InvalidIndexTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); tab.AddSubmit(); Assert.Throws(() => tab.SelectPosts(new[] { -1 })); @@ -154,16 +154,16 @@ public void EnqueueRemovePost_Test() UnreadManage = true, }; - tab.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 110L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 120L, IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("110"), IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("120"), IsRead = false }); tab.AddSubmit(); Assert.Equal(3, tab.AllCount); Assert.Equal(3, tab.UnreadCount); - tab.EnqueueRemovePost(100L, setIsDeleted: false); + tab.EnqueueRemovePost(new TwitterStatusId("100"), setIsDeleted: false); // この時点では削除は行われない Assert.Equal(3, tab.AllCount); @@ -173,8 +173,8 @@ public void EnqueueRemovePost_Test() Assert.Equal(2, tab.AllCount); Assert.Equal(2, tab.UnreadCount); - Assert.Equal(new[] { 110L, 120L }, tab.StatusIds); - Assert.Equal(new[] { 100L }, removedIds.AsEnumerable()); + Assert.Equal(new[] { new TwitterStatusId("110"), new TwitterStatusId("120") }, tab.StatusIds); + Assert.Equal(new[] { new TwitterStatusId("100") }, removedIds.AsEnumerable()); } [Fact] @@ -185,14 +185,14 @@ public void EnqueueRemovePost_SetIsDeletedTest() UnreadManage = true, }; - var post = new PostClass { StatusId = 100L, IsRead = false }; + var post = new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }; tab.AddPostQueue(post); tab.AddSubmit(); Assert.Equal(1, tab.AllCount); Assert.Equal(1, tab.UnreadCount); - tab.EnqueueRemovePost(100L, setIsDeleted: true); + tab.EnqueueRemovePost(new TwitterStatusId("100"), setIsDeleted: true); // この時点ではタブからの削除は行われないが、PostClass.IsDeleted は true にセットされる Assert.Equal(1, tab.AllCount); @@ -203,7 +203,7 @@ public void EnqueueRemovePost_SetIsDeletedTest() Assert.Equal(0, tab.AllCount); Assert.Equal(0, tab.UnreadCount); - Assert.Equal(new[] { 100L }, removedIds.AsEnumerable()); + Assert.Equal(new[] { new TwitterStatusId("100") }, removedIds.AsEnumerable()); } [Fact] @@ -214,14 +214,14 @@ public void EnqueueRemovePost_UnknownIdTest() UnreadManage = true, }; - tab.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); tab.AddSubmit(); Assert.Equal(1, tab.AllCount); Assert.Equal(1, tab.UnreadCount); - // StatusId = 999L は存在しない - tab.EnqueueRemovePost(999L, setIsDeleted: false); + // StatusId = 999 は存在しない + tab.EnqueueRemovePost(new TwitterStatusId("999"), setIsDeleted: false); Assert.Equal(1, tab.AllCount); Assert.Equal(1, tab.UnreadCount); @@ -237,25 +237,50 @@ public void EnqueueRemovePost_UnknownIdTest() public void EnqueueRemovePost_SelectedTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L }); - tab.AddPostQueue(new PostClass { StatusId = 110L }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100") }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("110") }); tab.AddSubmit(); tab.SelectPosts(new[] { 0, 1 }); Assert.Equal(2, tab.AllCount); - Assert.Equal(new[] { 100L, 110L }, tab.SelectedStatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("110") }, tab.SelectedStatusIds); - tab.EnqueueRemovePost(100L, setIsDeleted: false); + tab.EnqueueRemovePost(new TwitterStatusId("100"), setIsDeleted: false); // この時点では変化しない Assert.Equal(2, tab.AllCount); - Assert.Equal(new[] { 100L, 110L }, tab.SelectedStatusIds); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("110") }, tab.SelectedStatusIds); tab.RemoveSubmit(); // 削除された発言の選択が解除される Assert.Equal(1, tab.AllCount); - Assert.Equal(new[] { 110L }, tab.SelectedStatusIds); + Assert.Equal(new[] { new TwitterStatusId("110") }, tab.SelectedStatusIds); + } + + [Fact] + public void ReplacePost_SuccessTest() + { + var tab = new PublicSearchTabModel("search"); + var origPost = new PostClass { StatusId = new TwitterStatusId("100") }; + tab.AddPostQueue(origPost); + tab.AddSubmit(); + + Assert.Same(origPost, tab.Posts[new TwitterStatusId("100")]); + + var newPost = new PostClass { StatusId = new TwitterStatusId("100"), InReplyToStatusId = new TwitterStatusId("200") }; + Assert.True(tab.ReplacePost(newPost)); + Assert.Same(newPost, tab.Posts[new TwitterStatusId("100")]); + } + + [Fact] + public void ReplacePost_FailedTest() + { + var tab = new PublicSearchTabModel("search"); + Assert.False(tab.Contains(new TwitterStatusId("100"))); + + var newPost = new PostClass { StatusId = new TwitterStatusId("100"), InReplyToStatusId = new TwitterStatusId("200") }; + Assert.False(tab.ReplacePost(newPost)); } [Fact] @@ -266,25 +291,25 @@ public void NextUnreadId_Test() tab.UnreadManage = true; // 未読なし - Assert.Equal(-1L, tab.NextUnreadId); + Assert.Null(tab.NextUnreadId); tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = false, // 未読 }); tab.AddSubmit(); - Assert.Equal(100L, tab.NextUnreadId); + Assert.Equal(new TwitterStatusId("100"), tab.NextUnreadId); tab.AddPostQueue(new PostClass { - StatusId = 50L, + StatusId = new TwitterStatusId("50"), IsRead = true, // 既読 }); tab.AddSubmit(); - Assert.Equal(100L, tab.NextUnreadId); + Assert.Equal(new TwitterStatusId("100"), tab.NextUnreadId); } [Fact] @@ -297,12 +322,12 @@ public void NextUnreadId_DisabledTest() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = false, // 未読 }); tab.AddSubmit(); - Assert.Equal(-1L, tab.NextUnreadId); + Assert.Null(tab.NextUnreadId); } [Fact] @@ -312,17 +337,32 @@ public void NextUnreadId_SortByIdAscTest() tab.UnreadManage = true; - // ID の昇順でソート + // ID (CreatedAtForSorting) の昇順でソート tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); // 画面には上から 100 → 200 → 300 の順に並ぶ - tab.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 200L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 300L, IsRead = false }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), + IsRead = false, + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("200"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), + IsRead = false, + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("300"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 3), + IsRead = false, + }); tab.AddSubmit(); - // 昇順/降順に関わらず、ID の小さい順に未読の ID を返す - Assert.Equal(100L, tab.NextUnreadId); + // 昇順/降順に関わらず、CreatedAtForSorting の小さい順に未読の ID を返す + Assert.Equal(new TwitterStatusId("100"), tab.NextUnreadId); } [Fact] @@ -332,17 +372,32 @@ public void NextUnreadId_SortByIdDescTest() tab.UnreadManage = true; - // ID の降順でソート + // ID (CreatedAtForSorting) の降順でソート tab.SetSortMode(ComparerMode.Id, SortOrder.Descending); // 画面には上から 300 → 200 → 100 の順に並ぶ - tab.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 200L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 300L, IsRead = false }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), + IsRead = false, + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("200"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), + IsRead = false, + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("300"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 3), + IsRead = false, + }); tab.AddSubmit(); - // 昇順/降順に関わらず、ID の小さい順に未読の ID を返す - Assert.Equal(100L, tab.NextUnreadId); + // 昇順/降順に関わらず、CreatedAtForSorting の小さい順に未読の ID を返す + Assert.Equal(new TwitterStatusId("100"), tab.NextUnreadId); } [Fact] @@ -356,13 +411,13 @@ public void NextUnreadId_SortByScreenNameAscTest() tab.SetSortMode(ComparerMode.Name, SortOrder.Ascending); // 画面には上から 200 → 100 → 300 の順に並ぶ - tab.AddPostQueue(new PostClass { StatusId = 100L, ScreenName = "bbb", IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 200L, ScreenName = "aaa", IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 300L, ScreenName = "ccc", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "bbb", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "aaa", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc", IsRead = false }); tab.AddSubmit(); // 昇順/降順に関わらず、ScreenName の辞書順で小さい順に未読の ID を返す - Assert.Equal(200L, tab.NextUnreadId); + Assert.Equal(new TwitterStatusId("200"), tab.NextUnreadId); } [Fact] @@ -376,13 +431,13 @@ public void NextUnreadId_SortByScreenNameDescTest() tab.SetSortMode(ComparerMode.Name, SortOrder.Descending); // 画面には上から 300 → 100 → 200 の順に並ぶ - tab.AddPostQueue(new PostClass { StatusId = 100L, ScreenName = "bbb", IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 200L, ScreenName = "aaa", IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 300L, ScreenName = "ccc", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), ScreenName = "bbb", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), ScreenName = "aaa", IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("300"), ScreenName = "ccc", IsRead = false }); tab.AddSubmit(); // 昇順/降順に関わらず、ScreenName の辞書順で小さい順に未読の ID を返す - Assert.Equal(200L, tab.NextUnreadId); + Assert.Equal(new TwitterStatusId("200"), tab.NextUnreadId); } [Fact] @@ -397,7 +452,7 @@ public void UnreadCount_Test() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = false, // 未読 }); tab.AddSubmit(); @@ -406,7 +461,7 @@ public void UnreadCount_Test() tab.AddPostQueue(new PostClass { - StatusId = 50L, + StatusId = new TwitterStatusId("50"), IsRead = true, // 既読 }); tab.AddSubmit(); @@ -424,7 +479,7 @@ public void UnreadCount_DisabledTest() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = false, // 未読 }); tab.AddSubmit(); @@ -445,17 +500,20 @@ public void NextUnreadIndex_Test() tab.AddPostQueue(new PostClass { - StatusId = 50L, + StatusId = new TwitterStatusId("50"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), IsRead = true, // 既読 }); tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), IsRead = false, // 未読 }); tab.AddPostQueue(new PostClass { - StatusId = 150L, + StatusId = new TwitterStatusId("150"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 3), IsRead = false, // 未読 }); tab.AddSubmit(); @@ -474,7 +532,8 @@ public void NextUnreadIndex_DisabledTest() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), IsRead = false, // 未読 }); tab.AddSubmit(); @@ -490,13 +549,13 @@ public void GetUnreadIds_Test() Assert.Empty(tab.GetUnreadIds()); - tab.AddPostQueue(new PostClass { StatusId = 100L, IsRead = false }); - tab.AddPostQueue(new PostClass { StatusId = 200L, IsRead = true }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("100"), IsRead = false }); + tab.AddPostQueue(new PostClass { StatusId = new TwitterStatusId("200"), IsRead = true }); tab.AddSubmit(); - Assert.Equal(new[] { 100L }, tab.GetUnreadIds()); + Assert.Equal(new[] { new TwitterStatusId("100") }, tab.GetUnreadIds()); - tab.SetReadState(100L, true); // 既読にする + tab.SetReadState(new TwitterStatusId("100"), true); // 既読にする Assert.Empty(tab.GetUnreadIds()); } @@ -510,14 +569,14 @@ public void SetReadState_MarkAsReadTest() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = false, // 未読 }); tab.AddSubmit(); Assert.Equal(1, tab.UnreadCount); - tab.SetReadState(100L, true); // 既読にする + tab.SetReadState(new TwitterStatusId("100"), true); // 既読にする Assert.Equal(0, tab.UnreadCount); } @@ -531,14 +590,14 @@ public void SetReadState_MarkAsUnreadTest() tab.AddPostQueue(new PostClass { - StatusId = 100L, + StatusId = new TwitterStatusId("100"), IsRead = true, // 既読 }); tab.AddSubmit(); Assert.Equal(0, tab.UnreadCount); - tab.SetReadState(100L, false); // 未読にする + tab.SetReadState(new TwitterStatusId("100"), false); // 未読にする Assert.Equal(1, tab.UnreadCount); } @@ -619,11 +678,46 @@ public void SearchPostsAll_Test() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd", ScreenName = "", Nickname = "" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh", ScreenName = "", Nickname = "" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl", ScreenName = "", Nickname = "" }); // 2 - tab.AddPostQueue(new PostClass { StatusId = 130L, TextFromApi = "abc", ScreenName = "", Nickname = "" }); // 3 - tab.AddPostQueue(new PostClass { StatusId = 140L, TextFromApi = "def", ScreenName = "", Nickname = "" }); // 4 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("130"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 3), // 3 + TextFromApi = "abc", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("140"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 4), // 4 + TextFromApi = "def", + ScreenName = "", + Nickname = "", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); @@ -642,11 +736,46 @@ public void SearchPostsAll_ReverseOrderTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd", ScreenName = "", Nickname = "" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh", ScreenName = "", Nickname = "" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl", ScreenName = "", Nickname = "" }); // 2 - tab.AddPostQueue(new PostClass { StatusId = 130L, TextFromApi = "abc", ScreenName = "", Nickname = "" }); // 3 - tab.AddPostQueue(new PostClass { StatusId = 140L, TextFromApi = "def", ScreenName = "", Nickname = "" }); // 4 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("130"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 3), // 3 + TextFromApi = "abc", + ScreenName = "", + Nickname = "", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("140"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 4), // 4 + TextFromApi = "def", + ScreenName = "", + Nickname = "", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); @@ -665,15 +794,30 @@ public void GetterSingle_Test() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl" }); // 2 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); - Assert.Equal(100L, tab[0].StatusId); - Assert.Equal(120L, tab[2].StatusId); + Assert.Equal(new TwitterStatusId("100"), tab[0].StatusId); + Assert.Equal(new TwitterStatusId("120"), tab[2].StatusId); } [Fact] @@ -681,9 +825,24 @@ public void GetterSingle_ErrorTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl" }); // 2 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); @@ -697,16 +856,31 @@ public void GetterSlice_Test() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl" }); // 2 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); - Assert.Equal(new[] { 100L, 110L, 120L }, tab[0, 2].Select(x => x.StatusId)); - Assert.Equal(new[] { 100L }, tab[0, 0].Select(x => x.StatusId)); - Assert.Equal(new[] { 120L }, tab[2, 2].Select(x => x.StatusId)); + Assert.Equal(new[] { new TwitterStatusId("100"), new TwitterStatusId("110"), new TwitterStatusId("120") }, tab[0, 2].Select(x => x.StatusId)); + Assert.Equal(new[] { new TwitterStatusId("100") }, tab[0, 0].Select(x => x.StatusId)); + Assert.Equal(new[] { new TwitterStatusId("120") }, tab[2, 2].Select(x => x.StatusId)); } [Fact] @@ -714,9 +888,24 @@ public void GetterSlice_ErrorTest() { var tab = new PublicSearchTabModel("search"); - tab.AddPostQueue(new PostClass { StatusId = 100L, TextFromApi = "abcd" }); // 0 - tab.AddPostQueue(new PostClass { StatusId = 110L, TextFromApi = "efgh" }); // 1 - tab.AddPostQueue(new PostClass { StatusId = 120L, TextFromApi = "ijkl" }); // 2 + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("100"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 0), // 0 + TextFromApi = "abcd", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("110"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 1), // 1 + TextFromApi = "efgh", + }); + tab.AddPostQueue(new() + { + StatusId = new TwitterStatusId("120"), + CreatedAtForSorting = new(2023, 1, 1, 0, 0, 2), // 2 + TextFromApi = "ijkl", + }); tab.SetSortMode(ComparerMode.Id, SortOrder.Ascending); tab.AddSubmit(); diff --git a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs index a62d8a347..1c95a4086 100644 --- a/OpenTween.Tests/Models/TwitterPostFactoryTest.cs +++ b/OpenTween.Tests/Models/TwitterPostFactoryTest.cs @@ -52,7 +52,7 @@ private TwitterStatus CreateStatus() IdStr = statusId.ToString(), CreatedAt = "Sat Jan 01 00:00:00 +0000 2022", FullText = "hoge", - Source = "OpenTween", + Source = """OpenTween""", Entities = new(), User = this.CreateUser(), }; @@ -79,7 +79,7 @@ public void CreateFromStatus_Test() var status = this.CreateStatus(); var post = factory.CreateFromStatus(status, selfUserId: 20000L, followerIds: EmptyIdSet); - Assert.Equal(status.Id, post.StatusId); + Assert.Equal(new TwitterStatusId(status.IdStr), post.StatusId); Assert.Equal(new DateTimeUtc(2022, 1, 1, 0, 0, 0), post.CreatedAt); Assert.Equal("hoge", post.Text); Assert.Equal("hoge", post.TextFromApi); @@ -160,13 +160,13 @@ public void CreateFromStatus_RetweetTest() var retweetStatus = this.CreateStatus(); retweetStatus.RetweetedStatus = originalStatus; - retweetStatus.Source = "Twitter Web App"; + retweetStatus.Source = """Twitter Web App"""; var post = factory.CreateFromStatus(retweetStatus, selfUserId: 20000L, followerIds: EmptyIdSet); - Assert.Equal(retweetStatus.Id, post.StatusId); + Assert.Equal(new TwitterStatusId(retweetStatus.IdStr), post.StatusId); Assert.Equal(retweetStatus.User.Id, post.RetweetedByUserId); - Assert.Equal(originalStatus.Id, post.RetweetedId); + Assert.Equal(new TwitterStatusId(originalStatus.IdStr), post.RetweetedId); Assert.Equal(originalStatus.User.Id, post.UserId); Assert.Equal("OpenTween", post.Source); @@ -228,7 +228,7 @@ public void CreateFromDirectMessageEvent_Test() 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 TwitterDirectMessageId(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); @@ -315,7 +315,7 @@ public void CreateFromStatus_MediaAltTest() 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.Text); Assert.Equal("pic.twitter.com/hoge", post.TextFromApi); Assert.Equal("pic.twitter.com/hoge", post.TextSingleLine); } @@ -345,7 +345,7 @@ public void CreateFromStatus_MediaNoAltTest() 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.Text); Assert.Equal("pic.twitter.com/hoge", post.TextFromApi); Assert.Equal("pic.twitter.com/hoge", post.TextSingleLine); } @@ -387,7 +387,7 @@ public void CreateFromStatus_QuotedUrlTest() 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.Text); Assert.Equal("twitter.com/hoge/status/1…", post.TextFromApi); Assert.Equal("twitter.com/hoge/status/1…", post.TextSingleLine); } @@ -422,7 +422,7 @@ public void CreateFromStatus_QuotedUrlWithPermelinkTest() 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.Text); Assert.Equal("hoge twitter.com/hoge/status/1…", post.TextFromApi); Assert.Equal("hoge twitter.com/hoge/status/1…", post.TextSingleLine); } @@ -453,7 +453,7 @@ public void CreateFromStatus_QuotedUrlNoReferenceTest() 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.Text); Assert.Equal("twitter.com/hoge/status/1…", post.TextFromApi); Assert.Equal("twitter.com/hoge/status/1…", post.TextSingleLine); } @@ -484,9 +484,9 @@ public void CreateHtmlAnchor_Test() }, }; - var expectedHtml = @"@twitterapi" - + @" #BreakingMyTwitter" - + @" apps-of-a-feather.com"; + var expectedHtml = """@twitterapi""" + + """ #BreakingMyTwitter""" + + """ apps-of-a-feather.com"""; Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink: null)); } @@ -497,7 +497,7 @@ public void CreateHtmlAnchor_NicovideoTest() var text = "sm9"; var entities = new TwitterEntities(); - var expectedHtml = @"sm9"; + var expectedHtml = """sm9"""; Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink: null)); } @@ -515,7 +515,7 @@ public void CreateHtmlAnchor_QuotedUrlWithPermelinkTest() }; var expectedHtml = @"hoge" - + @" twitter.com/hoge/status/1…"; + + """ twitter.com/hoge/status/1…"""; Assert.Equal(expectedHtml, TwitterPostFactory.CreateHtmlAnchor(text, entities, quotedStatusLink)); } @@ -523,7 +523,7 @@ public void CreateHtmlAnchor_QuotedUrlWithPermelinkTest() [Fact] public void ParseSource_Test() { - var sourceHtml = "Twitter Web Client"; + var sourceHtml = """Twitter Web Client"""; var expected = ("Twitter Web Client", new Uri("http://twitter.com/")); Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml)); @@ -542,7 +542,7 @@ public void ParseSource_PlainTextTest() public void ParseSource_RelativeUriTest() { // 参照: https://twitter.com/kim_upsilon/status/477796052049752064 - var sourceHtml = "erased_45416"; + var sourceHtml = """erased_45416"""; var expected = ("erased_45416", new Uri("https://twitter.com/erased_45416")); Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml)); @@ -570,7 +570,7 @@ public void ParseSource_NullTest() [Fact] public void ParseSource_UnescapeTest() { - var sourceHtml = "<<hogehoge>>"; + var sourceHtml = """<<hogehoge>>"""; var expected = ("<>", new Uri("http://example.com/?aaa=123&bbb=456")); Assert.Equal(expected, TwitterPostFactory.ParseSource(sourceHtml)); @@ -598,7 +598,7 @@ public void GetQuoteTweetStatusIds_EntityTest() }; var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(entities, quotedStatusLink: null); - Assert.Equal(new[] { 599261132361072640L }, statusIds); + Assert.Equal(new[] { new TwitterStatusId("599261132361072640") }, statusIds); } [Fact] @@ -612,7 +612,7 @@ public void GetQuoteTweetStatusIds_QuotedStatusLinkTest() }; var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(entities, quotedStatusLink); - Assert.Equal(new[] { 599261132361072640L }, statusIds); + Assert.Equal(new[] { new TwitterStatusId("599261132361072640") }, statusIds); } [Fact] @@ -624,20 +624,35 @@ public void GetQuoteTweetStatusIds_UrlStringTest() }; var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(urls); - Assert.Equal(new[] { 599261132361072640L }, statusIds); + Assert.Equal(new[] { new TwitterStatusId("599261132361072640") }, statusIds); } [Fact] - public void GetQuoteTweetStatusIds_OverflowTest() + public void ParseDateTimeFromSnowflakeId_LowerTest() { - var urls = new[] - { - // 符号付き 64 ビット整数の範囲を超える値 - "https://twitter.com/kim_upsilon/status/9999999999999999999", - }; + var statusId = 1659990873340346368L; + var createdAtStr = "Sat May 20 18:34:00 +0000 2023"; + var expected = new DateTimeUtc(2023, 5, 20, 18, 34, 0, 0); + Assert.Equal(expected, TwitterPostFactory.ParseDateTimeFromSnowflakeId(statusId, createdAtStr)); + } - var statusIds = TwitterPostFactory.GetQuoteTweetStatusIds(urls); - Assert.Empty(statusIds); + [Fact] + public void ParseDateTimeFromSnowflakeId_UpperTest() + { + var statusId = 1672312060766748673L; + var createdAtStr = "Fri Jun 23 18:33:59 +0000 2023"; + var expected = new DateTimeUtc(2023, 6, 23, 18, 33, 59, 999); + Assert.Equal(expected, TwitterPostFactory.ParseDateTimeFromSnowflakeId(statusId, createdAtStr)); + } + + [Fact] + public void ParseDateTimeFromSnowflakeId_FallbackTest() + { + // Snowflake 導入以前の status_id に対しては created_at の文字列からパースした日時を採用する + var statusId = 20L; + var createdAtStr = "Tue Mar 21 20:50:14 +0000 2006"; + var expected = new DateTimeUtc(2006, 3, 21, 20, 50, 14, 0); + Assert.Equal(expected, TwitterPostFactory.ParseDateTimeFromSnowflakeId(statusId, createdAtStr)); } } } diff --git a/OpenTween.Tests/MyCommonTest.cs b/OpenTween.Tests/MyCommonTest.cs index e6860d72e..71ffbf50b 100644 --- a/OpenTween.Tests/MyCommonTest.cs +++ b/OpenTween.Tests/MyCommonTest.cs @@ -128,7 +128,7 @@ public struct JsonData public static readonly TheoryData CreateDataFromJsonTestCase = new() { { - @"{""id"":""1"", ""body"":""hogehoge""}", + """{"id":"1", "body":"hogehoge"}""", new JsonData { Id = "1", Body = "hogehoge" } }, }; @@ -189,11 +189,11 @@ public void GetReadableVersionTest(string fileVersion, string expected) public static readonly TheoryData GetStatusUrlTest1TestCase = new() { { - new PostClass { StatusId = 249493863826350080L, ScreenName = "Favstar_LM", RetweetedId = null, RetweetedBy = null }, + new PostClass { StatusId = new TwitterStatusId("249493863826350080"), ScreenName = "Favstar_LM", RetweetedId = null, RetweetedBy = null }, "https://twitter.com/Favstar_LM/status/249493863826350080" }, { - new PostClass { StatusId = 216033842434289664L, ScreenName = "haru067", RetweetedId = 200245741443235840L, RetweetedBy = "re4k" }, + new PostClass { StatusId = new TwitterStatusId("216033842434289664"), ScreenName = "haru067", RetweetedId = new TwitterStatusId("200245741443235840"), RetweetedBy = "re4k" }, "https://twitter.com/haru067/status/200245741443235840" }, }; @@ -207,7 +207,7 @@ public void GetStatusUrlTest1(PostClass post, string expected) [InlineData("Favstar_LM", 249493863826350080L, "https://twitter.com/Favstar_LM/status/249493863826350080")] [InlineData("haru067", 200245741443235840L, "https://twitter.com/haru067/status/200245741443235840")] public void GetStatusUrlTest2(string screenName, long statusId, string expected) - => Assert.Equal(expected, MyCommon.GetStatusUrl(screenName, statusId)); + => Assert.Equal(expected, MyCommon.GetStatusUrl(screenName, new TwitterStatusId(statusId))); [Fact] public void GetErrorLogPathTest() diff --git a/OpenTween.Tests/OpenTween.Tests.csproj b/OpenTween.Tests/OpenTween.Tests.csproj index 25147b7e3..f1722591e 100644 --- a/OpenTween.Tests/OpenTween.Tests.csproj +++ b/OpenTween.Tests/OpenTween.Tests.csproj @@ -3,7 +3,7 @@ OpenTween net48 - 10.0 + 11.0 enable true @@ -25,18 +25,18 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,5 +52,20 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_Conversation.json b/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_Conversation.json new file mode 100644 index 000000000..e5e844ca3 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_Conversation.json @@ -0,0 +1,1282 @@ +{ + "data": { + "list": { + "tweets_timeline": { + "timeline": { + "instructions": [ + { + "type": "TimelineAddEntries", + "entries": [ + { + "entryId": "list-conversation-1675866022877331462", + "sortIndex": "1675866022877331450", + "content": { + "entryType": "TimelineTimelineModule", + "__typename": "TimelineTimelineModule", + "items": [ + { + "entryId": "list-conversation-1675866022877331462-tweet-1616184396092178433", + "item": { + "itemContent": { + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1616184396092178433", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1616184396092178433" + ], + "editable_until_msecs": "1674165161000", + "is_edit_eligible": false, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "1244", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "quoted_status_result": { + "result": { + "__typename": "Tweet", + "rest_id": "1616182499641819136", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo1MTQyNDE4MDE=", + "rest_id": "514241801", + "affiliates_highlighted_label": {}, + "has_graduated_access": false, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": true, + "can_media_tag": false, + "created_at": "Sun Mar 04 11:33:45 +0000 2012", + "default_profile": false, + "default_profile_image": false, + "description": "Windows 用 Twitter クライアント OpenTween のアカウントです。", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "opentween.org", + "expanded_url": "https://www.opentween.org/", + "url": "https://t.co/An6OJeC28u", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 0, + "followers_count": 301, + "friends_count": 1, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 14, + "location": "", + "media_count": 0, + "name": "OpenTween", + "normal_followers_count": 301, + "pinned_tweet_ids_str": [ + "1617124615347908609" + ], + "possibly_sensitive": false, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/661168792488153088/-UAFci6G_normal.png", + "profile_interstitial_type": "", + "screen_name": "opentween", + "statuses_count": 31, + "translator_type": "none", + "url": "https://t.co/An6OJeC28u", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + }, + "card": { + "rest_id": "https://t.co/LvEPrVETIQ", + "legacy": { + "binding_values": [ + { + "key": "photo_image_full_size_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image", + "value": { + "image_value": { + "height": 200, + "width": 400, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=400x400" + }, + "type": "IMAGE" + } + }, + { + "key": "description", + "value": { + "string_value": "==== Ver 3.2.0(2023/01/20) NEW: 複数枚の画像を添付する際にリスト上で画像を確認できるようになりました CHG: アカウント追加時の認可関連のエラーメッセージがより詳細になるように変更", + "type": "STRING" + } + }, + { + "key": "domain", + "value": { + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_large", + "value": { + "image_value": { + "height": 300, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=600x600" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "site", + "value": { + "scribe_key": "publisher_id", + "type": "USER", + "user_value": { + "id_str": "13334762", + "path": [] + } + } + }, + { + "key": "photo_image_full_size_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_small", + "value": { + "image_value": { + "height": 72, + "width": 144, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=144x144" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_alt_text", + "value": { + "string_value": "==== Ver 3.2.0(2023/01/20) NEW: 複数枚の画像を添付する際にリスト上で画像を確認できるようになりました CHG: アカウント追加時の認可関連のエラーメッセージがより詳細になるように変更", + "type": "STRING" + } + }, + { + "key": "vanity_url", + "value": { + "scribe_key": "vanity_url", + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "photo_image_full_size", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_alt_text", + "value": { + "string_value": "==== Ver 3.2.0(2023/01/20) NEW: 複数枚の画像を添付する際にリスト上で画像を確認できるようになりました CHG: アカウント追加時の認可関連のエラーメッセージがより詳細になるように変更", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 40, + "green": 55, + "red": 50 + }, + "percentage": 0.98 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "title", + "value": { + "string_value": "Release OpenTween v3.2.0 · opentween/OpenTween", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 40, + "green": 55, + "red": 50 + }, + "percentage": 0.98 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "summary_photo_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 40, + "green": 55, + "red": 50 + }, + "percentage": 0.98 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "photo_image_full_size_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "card_url", + "value": { + "scribe_key": "card_url", + "string_value": "https://t.co/LvEPrVETIQ", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1674655953424207872/tp2h8UWj?format=jpg&name=orig" + }, + "type": "IMAGE" + } + } + ], + "card_platform": { + "platform": { + "audience": { + "name": "production" + }, + "device": { + "name": "Swift", + "version": "12" + } + } + }, + "name": "summary_large_image", + "url": "https://t.co/LvEPrVETIQ", + "user_refs_results": [ + { + "result": { + "__typename": "User", + "id": "VXNlcjoxMzMzNDc2Mg==", + "rest_id": "13334762", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": true, + "profile_image_shape": "Square", + "legacy": { + "can_dm": false, + "can_media_tag": true, + "created_at": "Mon Feb 11 04:41:50 +0000 2008", + "default_profile": false, + "default_profile_image": false, + "description": "The AI-powered developer platform to build, scale, and deliver secure software.", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "github.com", + "expanded_url": "http://github.com", + "url": "https://t.co/bbJgfyzcJR", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 8055, + "followers_count": 2511440, + "friends_count": 334, + "has_custom_timelines": true, + "is_translator": false, + "listed_count": 18239, + "location": "San Francisco, CA", + "media_count": 2063, + "name": "GitHub", + "normal_followers_count": 2511440, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/13334762/1680541755", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png", + "profile_interstitial_type": "", + "screen_name": "github", + "statuses_count": 8343, + "translator_type": "none", + "url": "https://t.co/bbJgfyzcJR", + "verified": false, + "verified_type": "Business", + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + ] + } + }, + "unified_card": { + "card_fetch_state": "NoCard" + }, + "edit_control": { + "edit_tweet_ids": [ + "1616182499641819136" + ], + "editable_until_msecs": "1674164709000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "7527", + "state": "EnabledWithCount" + }, + "source": "OpenTween", + "legacy": { + "bookmark_count": 1, + "bookmarked": false, + "created_at": "Thu Jan 19 21:15:09 +0000 2023", + "conversation_id_str": "1616182499641819136", + "display_text_range": [ + 0, + 74 + ], + "entities": { + "user_mentions": [], + "urls": [ + { + "display_url": "osdn.net/projects/opent…", + "expanded_url": "https://osdn.net/projects/opentween/releases/78187", + "url": "https://t.co/8lcm1hhdfS", + "indices": [ + 27, + 50 + ] + }, + { + "display_url": "github.com/opentween/Open…", + "expanded_url": "https://github.com/opentween/OpenTween/releases/tag/OpenTween_v3.2.0", + "url": "https://t.co/LvEPrVETIQ", + "indices": [ + 51, + 74 + ] + } + ], + "hashtags": [ + { + "indices": [ + 0, + 10 + ], + "text": "OpenTween" + } + ], + "symbols": [] + }, + "favorite_count": 20, + "favorited": false, + "full_text": "#OpenTween v3.2.0 リリースしました\nhttps://t.co/8lcm1hhdfS\nhttps://t.co/LvEPrVETIQ", + "is_quote_status": false, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 3, + "reply_count": 3, + "retweet_count": 22, + "retweeted": false, + "user_id_str": "514241801", + "id_str": "1616182499641819136" + } + } + }, + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Thu Jan 19 21:22:41 +0000 2023", + "conversation_id_str": "1616184396092178433", + "display_text_range": [ + 0, + 60 + ], + "entities": { + "user_mentions": [], + "urls": [ + { + "display_url": "twitter.com/opentween/stat…", + "expanded_url": "https://twitter.com/opentween/status/1616182499641819136", + "url": "https://t.co/bzvF5kDXeE", + "indices": [ + 37, + 60 + ] + } + ], + "hashtags": [ + { + "indices": [ + 26, + 36 + ], + "text": "OpenTween" + } + ], + "symbols": [] + }, + "favorite_count": 5, + "favorited": false, + "full_text": "画像を添付する時の画面が見やすくなりました(主観) #OpenTween\nhttps://t.co/bzvF5kDXeE", + "is_quote_status": true, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 0, + "quoted_status_id_str": "1616182499641819136", + "quoted_status_permalink": { + "url": "https://t.co/bzvF5kDXeE", + "expanded": "https://twitter.com/opentween/status/1616182499641819136", + "display": "twitter.com/opentween/stat…" + }, + "reply_count": 1, + "retweet_count": 1, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1616184396092178433" + } + } + }, + "tweetDisplayType": "Tweet" + }, + "clientEventInfo": { + "component": "suggest_organic_list_tweet", + "element": "tweet", + "details": { + "timelinesDetails": { + "injectionType": "OrganicListTweet" + } + } + } + } + }, + { + "entryId": "list-conversation-1675866022877331462-tweet-1616185165583351809", + "item": { + "itemContent": { + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1616185165583351809", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1616185165583351809" + ], + "editable_until_msecs": "1674165345000", + "is_edit_eligible": false, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "653", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Thu Jan 19 21:25:45 +0000 2023", + "conversation_id_str": "1616184396092178433", + "display_text_range": [ + 0, + 47 + ], + "entities": { + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 0, + "favorited": false, + "full_text": "あとで細かい使い勝手の調整はするつもり。画像の順番を入れ替えたりとか、途中の画像を消したりとか", + "in_reply_to_screen_name": "kim_upsilon", + "in_reply_to_status_id_str": "1616184396092178433", + "in_reply_to_user_id_str": "40480664", + "is_quote_status": false, + "lang": "ja", + "quote_count": 0, + "reply_count": 1, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1616185165583351809" + } + } + }, + "tweetDisplayType": "Tweet" + }, + "clientEventInfo": { + "component": "suggest_organic_list_tweet", + "element": "tweet", + "details": { + "timelinesDetails": { + "injectionType": "OrganicListTweet" + } + } + } + } + }, + { + "entryId": "list-conversation-1675866022877331462-tweet-1616193114699628545", + "item": { + "itemContent": { + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1616193114699628545", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "card": { + "rest_id": "https://t.co/p8rFeE4bV8", + "legacy": { + "binding_values": [ + { + "key": "thumbnail_image", + "value": { + "image_value": { + "height": 144, + "width": 144, + "url": "https://pbs.twimg.com/card_img/1675000140871143425/jnI1-BdH?format=jpg&name=144x144_2" + }, + "type": "IMAGE" + } + }, + { + "key": "domain", + "value": { + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_large", + "value": { + "image_value": { + "height": 420, + "width": 420, + "url": "https://pbs.twimg.com/card_img/1675000140871143425/jnI1-BdH?format=jpg&name=420x420_2" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675000140871143425/jnI1-BdH?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_small", + "value": { + "image_value": { + "height": 100, + "width": 100, + "url": "https://pbs.twimg.com/card_img/1675000140871143425/jnI1-BdH?format=jpg&name=100x100_2" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675000140871143425/jnI1-BdH?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "vanity_url", + "value": { + "scribe_key": "vanity_url", + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 88.31 + }, + { + "rgb": { + "blue": 124, + "green": 119, + "red": 115 + }, + "percentage": 6.63 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.15 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.71 + }, + { + "rgb": { + "blue": 211, + "green": 208, + "red": 243 + }, + "percentage": 0.15 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "title", + "value": { + "string_value": "添付画像に同一の画像を続けて追加すると表示中の画像が破棄される不具合を修正 by upsilon · Pull Request #189 · opentween/OpenTween", + "type": "STRING" + } + }, + { + "key": "card_url", + "value": { + "scribe_key": "card_url", + "string_value": "https://t.co/p8rFeE4bV8", + "type": "STRING" + } + } + ], + "card_platform": { + "platform": { + "audience": { + "name": "production" + }, + "device": { + "name": "Swift", + "version": "12" + } + } + }, + "name": "summary", + "url": "https://t.co/p8rFeE4bV8", + "user_refs_results": [] + } + }, + "unified_card": { + "card_fetch_state": "NoCard" + }, + "edit_control": { + "edit_tweet_ids": [ + "1616193114699628545" + ], + "editable_until_msecs": "1674167240000", + "is_edit_eligible": false, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "924", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Thu Jan 19 21:57:20 +0000 2023", + "conversation_id_str": "1616184396092178433", + "display_text_range": [ + 0, + 144 + ], + "entities": { + "user_mentions": [], + "urls": [ + { + "display_url": "github.com/opentween/Open…", + "expanded_url": "https://github.com/opentween/OpenTween/pull/189", + "url": "https://t.co/p8rFeE4bV8", + "indices": [ + 121, + 144 + ] + } + ], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 12, + "favorited": false, + "full_text": "リリースした直後にデバッグ能力が500倍になるのやめたい\n\n添付画像に同一の画像を続けて追加すると表示中の画像が破棄される不具合を修正 by upsilon · Pull Request #189 · opentween/OpenTween https://t.co/p8rFeE4bV8", + "in_reply_to_screen_name": "kim_upsilon", + "in_reply_to_status_id_str": "1616185165583351809", + "in_reply_to_user_id_str": "40480664", + "is_quote_status": false, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 0, + "reply_count": 0, + "retweet_count": 1, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1616193114699628545" + } + } + }, + "tweetDisplayType": "Tweet" + }, + "clientEventInfo": { + "component": "suggest_organic_list_tweet", + "element": "tweet", + "details": { + "timelinesDetails": { + "injectionType": "OrganicListTweet" + } + } + } + } + } + ], + "metadata": { + "conversationMetadata": { + "allTweetIds": [ + "1616184396092178433", + "1616185165583351809", + "1616193114699628545" + ], + "enableDeduplication": true + } + }, + "displayType": "VerticalConversation", + "clientEventInfo": { + "component": "suggest_organic_list_tweet", + "details": { + "timelinesDetails": { + "injectionType": "OrganicListTweet" + } + } + } + } + }, + { + "entryId": "cursor-top-1675866022877331457", + "sortIndex": "1675866022877331457", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF0HfRMjAJxEKAAIWes8rE1oQAAgAAwAAAAEAAA", + "cursorType": "Top" + } + }, + { + "entryId": "cursor-bottom-1675866022877331382", + "sortIndex": "1675866022877331382", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF0HfRMi__7QKAAIVAxUYmFWQAwgAAwAAAAIAAA", + "cursorType": "Bottom" + } + } + ] + } + ], + "metadata": { + "scribeConfig": { + "page": "list_tweets" + } + } + } + } + } + } +} diff --git a/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_SimpleTweet.json b/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_SimpleTweet.json new file mode 100644 index 000000000..58bdd2804 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/ListLatestTweetsTimeline_SimpleTweet.json @@ -0,0 +1,179 @@ +{ + "data": { + "list": { + "tweets_timeline": { + "timeline": { + "instructions": [ + { + "type": "TimelineAddEntries", + "entries": [ + { + "entryId": "tweet-1613784711020826626", + "sortIndex": "1675866022877331438", + "content": { + "entryType": "TimelineTimelineItem", + "__typename": "TimelineTimelineItem", + "itemContent": { + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1613784711020826626", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1613784711020826626" + ], + "editable_until_msecs": "1673593032000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "403", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 1, + "bookmarked": false, + "created_at": "Fri Jan 13 06:27:12 +0000 2023", + "conversation_id_str": "1613784711020826626", + "display_text_range": [ + 0, + 20 + ], + "entities": { + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 6, + "favorited": false, + "full_text": "この数年おきに来るAPI凍結騒動は何なの", + "is_quote_status": false, + "lang": "ja", + "quote_count": 0, + "reply_count": 0, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1613784711020826626" + } + } + }, + "tweetDisplayType": "Tweet" + }, + "clientEventInfo": { + "component": "suggest_organic_list_tweet", + "element": "tweet", + "details": { + "timelinesDetails": { + "injectionType": "OrganicListTweet" + } + } + } + } + }, + { + "entryId": "cursor-top-1675866022877331457", + "sortIndex": "1675866022877331457", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF0HfRMjAJxEKAAIWes8rE1oQAAgAAwAAAAEAAA", + "cursorType": "Top" + } + }, + { + "entryId": "cursor-bottom-1675866022877331382", + "sortIndex": "1675866022877331382", + "content": { + "entryType": "TimelineTimelineCursor", + "__typename": "TimelineTimelineCursor", + "value": "DAABCgABF0HfRMi__7QKAAIVAxUYmFWQAwgAAwAAAAIAAA", + "cursorType": "Bottom" + } + } + ] + } + ], + "metadata": { + "scribeConfig": { + "page": "list_tweets" + } + } + } + } + } + } +} diff --git a/OpenTween.Tests/Resources/Responses/TimelineTweet_RetweetedTweet.json b/OpenTween.Tests/Resources/Responses/TimelineTweet_RetweetedTweet.json new file mode 100644 index 000000000..6204d25e8 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/TimelineTweet_RetweetedTweet.json @@ -0,0 +1,1227 @@ +{ + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1617128268548964354", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "card": { + "rest_id": "https://t.co/HPLtWszxIr", + "legacy": { + "binding_values": [ + { + "key": "photo_image_full_size_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image", + "value": { + "image_value": { + "height": 200, + "width": 400, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=400x400" + }, + "type": "IMAGE" + } + }, + { + "key": "description", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "domain", + "value": { + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_large", + "value": { + "image_value": { + "height": 300, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x600" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "site", + "value": { + "scribe_key": "publisher_id", + "type": "USER", + "user_value": { + "id_str": "13334762", + "path": [] + } + } + }, + { + "key": "photo_image_full_size_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_small", + "value": { + "image_value": { + "height": 72, + "width": 144, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=144x144" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_alt_text", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "vanity_url", + "value": { + "scribe_key": "vanity_url", + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "photo_image_full_size", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_alt_text", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "title", + "value": { + "string_value": "Release OpenTween v3.3.0 · opentween/OpenTween", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "summary_photo_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "photo_image_full_size_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "card_url", + "value": { + "scribe_key": "card_url", + "string_value": "https://t.co/HPLtWszxIr", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + } + ], + "card_platform": { + "platform": { + "audience": { + "name": "production" + }, + "device": { + "name": "Swift", + "version": "12" + } + } + }, + "name": "summary_large_image", + "url": "https://t.co/HPLtWszxIr", + "user_refs_results": [ + { + "result": { + "__typename": "User", + "id": "VXNlcjoxMzMzNDc2Mg==", + "rest_id": "13334762", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": true, + "profile_image_shape": "Square", + "legacy": { + "can_dm": false, + "can_media_tag": true, + "created_at": "Mon Feb 11 04:41:50 +0000 2008", + "default_profile": false, + "default_profile_image": false, + "description": "The AI-powered developer platform to build, scale, and deliver secure software.", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "github.com", + "expanded_url": "http://github.com", + "url": "https://t.co/bbJgfyzcJR", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 8055, + "followers_count": 2511440, + "friends_count": 334, + "has_custom_timelines": true, + "is_translator": false, + "listed_count": 18239, + "location": "San Francisco, CA", + "media_count": 2063, + "name": "GitHub", + "normal_followers_count": 2511440, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/13334762/1680541755", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png", + "profile_interstitial_type": "", + "screen_name": "github", + "statuses_count": 8343, + "translator_type": "none", + "url": "https://t.co/bbJgfyzcJR", + "verified": false, + "verified_type": "Business", + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + ] + } + }, + "unified_card": { + "card_fetch_state": "NoCard" + }, + "edit_control": { + "edit_tweet_ids": [ + "1617128268548964354" + ], + "editable_until_msecs": "1674390198669", + "is_edit_eligible": false, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "state": "Enabled" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Sun Jan 22 11:53:18 +0000 2023", + "conversation_id_str": "1617128268548964354", + "display_text_range": [ + 0, + 89 + ], + "entities": { + "user_mentions": [ + { + "id_str": "514241801", + "name": "OpenTween", + "screen_name": "opentween", + "indices": [ + 3, + 13 + ] + } + ], + "urls": [ + { + "display_url": "osdn.net/projects/opent…", + "expanded_url": "https://osdn.net/projects/opentween/releases/78197", + "url": "https://t.co/nQja6BhnU6", + "indices": [ + 42, + 65 + ] + }, + { + "display_url": "github.com/opentween/Open…", + "expanded_url": "https://github.com/opentween/OpenTween/releases/tag/OpenTween_v3.3.0", + "url": "https://t.co/HPLtWszxIr", + "indices": [ + 66, + 89 + ] + } + ], + "hashtags": [ + { + "indices": [ + 15, + 25 + ], + "text": "OpenTween" + } + ], + "symbols": [] + }, + "favorite_count": 0, + "favorited": false, + "full_text": "RT @opentween: #OpenTween v3.3.0 リリースしました\nhttps://t.co/nQja6BhnU6\nhttps://t.co/HPLtWszxIr", + "is_quote_status": false, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 0, + "reply_count": 0, + "retweet_count": 254, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1617128268548964354", + "retweeted_status_result": { + "result": { + "__typename": "Tweet", + "rest_id": "1617126084138659840", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo1MTQyNDE4MDE=", + "rest_id": "514241801", + "affiliates_highlighted_label": {}, + "has_graduated_access": false, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "can_dm": true, + "can_media_tag": false, + "created_at": "Sun Mar 04 11:33:45 +0000 2012", + "default_profile": false, + "default_profile_image": false, + "description": "Windows 用 Twitter クライアント OpenTween のアカウントです。", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "opentween.org", + "expanded_url": "https://www.opentween.org/", + "url": "https://t.co/An6OJeC28u", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 0, + "followers_count": 301, + "friends_count": 1, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 14, + "location": "", + "media_count": 0, + "name": "OpenTween", + "normal_followers_count": 301, + "pinned_tweet_ids_str": [ + "1617124615347908609" + ], + "possibly_sensitive": false, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/661168792488153088/-UAFci6G_normal.png", + "profile_interstitial_type": "", + "screen_name": "opentween", + "statuses_count": 31, + "translator_type": "none", + "url": "https://t.co/An6OJeC28u", + "verified": false, + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + }, + "card": { + "rest_id": "https://t.co/HPLtWszxIr", + "legacy": { + "binding_values": [ + { + "key": "photo_image_full_size_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image", + "value": { + "image_value": { + "height": 200, + "width": 400, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=400x400" + }, + "type": "IMAGE" + } + }, + { + "key": "description", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "domain", + "value": { + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_large", + "value": { + "image_value": { + "height": 300, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x600" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "site", + "value": { + "scribe_key": "publisher_id", + "type": "USER", + "user_value": { + "id_str": "13334762", + "path": [] + } + } + }, + { + "key": "photo_image_full_size_small", + "value": { + "image_value": { + "height": 202, + "width": 386, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=386x202" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_large", + "value": { + "image_value": { + "height": 419, + "width": 800, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=800x419" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_small", + "value": { + "image_value": { + "height": 72, + "width": 144, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=144x144" + }, + "type": "IMAGE" + } + }, + { + "key": "thumbnail_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_alt_text", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "vanity_url", + "value": { + "scribe_key": "vanity_url", + "string_value": "github.com", + "type": "STRING" + } + }, + { + "key": "photo_image_full_size", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image_alt_text", + "value": { + "string_value": "==== Ver 3.3.0(2023/01/22) NEW: アカウント追加時にAPIキーを指定可能になりました CHG: API v2 の使用を設定状態に関わらず無効化しました FIX: 同一の画像を複数枚添付する時にプレビュー画像の表示がエラーになる不具合を修正", + "type": "STRING" + } + }, + { + "key": "thumbnail_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "title", + "value": { + "string_value": "Release OpenTween v3.3.0 · opentween/OpenTween", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "summary_photo_image_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "summary_photo_image", + "value": { + "image_value": { + "height": 314, + "width": 600, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=600x314" + }, + "type": "IMAGE" + } + }, + { + "key": "photo_image_full_size_color", + "value": { + "image_color_value": { + "palette": [ + { + "rgb": { + "blue": 255, + "green": 255, + "red": 255 + }, + "percentage": 91.56 + }, + { + "rgb": { + "blue": 1, + "green": 135, + "red": 23 + }, + "percentage": 3.12 + }, + { + "rgb": { + "blue": 105, + "green": 184, + "red": 118 + }, + "percentage": 1.56 + }, + { + "rgb": { + "blue": 36, + "green": 37, + "red": 39 + }, + "percentage": 0.96 + }, + { + "rgb": { + "blue": 152, + "green": 247, + "red": 255 + }, + "percentage": 0.37 + } + ] + }, + "type": "IMAGE_COLOR" + } + }, + { + "key": "photo_image_full_size_x_large", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=png&name=2048x2048_2_exp" + }, + "type": "IMAGE" + } + }, + { + "key": "card_url", + "value": { + "scribe_key": "card_url", + "string_value": "https://t.co/HPLtWszxIr", + "type": "STRING" + } + }, + { + "key": "summary_photo_image_original", + "value": { + "image_value": { + "height": 600, + "width": 1200, + "url": "https://pbs.twimg.com/card_img/1675573204138758145/wECzotsL?format=jpg&name=orig" + }, + "type": "IMAGE" + } + } + ], + "card_platform": { + "platform": { + "audience": { + "name": "production" + }, + "device": { + "name": "Swift", + "version": "12" + } + } + }, + "name": "summary_large_image", + "url": "https://t.co/HPLtWszxIr", + "user_refs_results": [ + { + "result": { + "__typename": "User", + "id": "VXNlcjoxMzMzNDc2Mg==", + "rest_id": "13334762", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": true, + "profile_image_shape": "Square", + "legacy": { + "can_dm": false, + "can_media_tag": true, + "created_at": "Mon Feb 11 04:41:50 +0000 2008", + "default_profile": false, + "default_profile_image": false, + "description": "The AI-powered developer platform to build, scale, and deliver secure software.", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "github.com", + "expanded_url": "http://github.com", + "url": "https://t.co/bbJgfyzcJR", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 8055, + "followers_count": 2511440, + "friends_count": 334, + "has_custom_timelines": true, + "is_translator": false, + "listed_count": 18239, + "location": "San Francisco, CA", + "media_count": 2063, + "name": "GitHub", + "normal_followers_count": 2511440, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/13334762/1680541755", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png", + "profile_interstitial_type": "", + "screen_name": "github", + "statuses_count": 8343, + "translator_type": "none", + "url": "https://t.co/bbJgfyzcJR", + "verified": false, + "verified_type": "Business", + "want_retweets": false, + "withheld_in_countries": [] + } + } + } + ] + } + }, + "unified_card": { + "card_fetch_state": "NoCard" + }, + "edit_control": { + "edit_tweet_ids": [ + "1617126084138659840" + ], + "editable_until_msecs": "1674389677000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "51826", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 24, + "bookmarked": false, + "created_at": "Sun Jan 22 11:44:37 +0000 2023", + "conversation_id_str": "1617126084138659840", + "display_text_range": [ + 0, + 74 + ], + "entities": { + "user_mentions": [], + "urls": [ + { + "display_url": "osdn.net/projects/opent…", + "expanded_url": "https://osdn.net/projects/opentween/releases/78197", + "url": "https://t.co/nQja6BhnU6", + "indices": [ + 27, + 50 + ] + }, + { + "display_url": "github.com/opentween/Open…", + "expanded_url": "https://github.com/opentween/OpenTween/releases/tag/OpenTween_v3.3.0", + "url": "https://t.co/HPLtWszxIr", + "indices": [ + 51, + 74 + ] + } + ], + "hashtags": [ + { + "indices": [ + 0, + 10 + ], + "text": "OpenTween" + } + ], + "symbols": [] + }, + "favorite_count": 168, + "favorited": false, + "full_text": "#OpenTween v3.3.0 リリースしました\nhttps://t.co/nQja6BhnU6\nhttps://t.co/HPLtWszxIr", + "is_quote_status": false, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 17, + "reply_count": 1, + "retweet_count": 254, + "retweeted": false, + "user_id_str": "514241801", + "id_str": "1617126084138659840" + } + } + } + } + } + }, + "tweetDisplayType": "Tweet" +} diff --git a/OpenTween.Tests/Resources/Responses/TimelineTweet_SimpleTweet.json b/OpenTween.Tests/Resources/Responses/TimelineTweet_SimpleTweet.json new file mode 100644 index 000000000..399ea23fa --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/TimelineTweet_SimpleTweet.json @@ -0,0 +1,120 @@ +{ + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1613784711020826626", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1613784711020826626" + ], + "editable_until_msecs": "1673593032000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "403", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 1, + "bookmarked": false, + "created_at": "Fri Jan 13 06:27:12 +0000 2023", + "conversation_id_str": "1613784711020826626", + "display_text_range": [ + 0, + 20 + ], + "entities": { + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "favorite_count": 6, + "favorited": false, + "full_text": "この数年おきに来るAPI凍結騒動は何なの", + "is_quote_status": false, + "lang": "ja", + "quote_count": 0, + "reply_count": 0, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1613784711020826626" + } + } + }, + "tweetDisplayType": "Tweet" +} diff --git a/OpenTween.Tests/Resources/Responses/TimelineTweet_TweetWithMedia.json b/OpenTween.Tests/Resources/Responses/TimelineTweet_TweetWithMedia.json new file mode 100644 index 000000000..0c3273936 --- /dev/null +++ b/OpenTween.Tests/Resources/Responses/TimelineTweet_TweetWithMedia.json @@ -0,0 +1,530 @@ +{ + "itemType": "TimelineTweet", + "__typename": "TimelineTweet", + "tweet_results": { + "result": { + "__typename": "Tweet", + "rest_id": "1614587968567783424", + "core": { + "user_results": { + "result": { + "__typename": "User", + "id": "VXNlcjo0MDQ4MDY2NA==", + "rest_id": "40480664", + "affiliates_highlighted_label": {}, + "has_graduated_access": true, + "is_blue_verified": false, + "profile_image_shape": "Circle", + "legacy": { + "followed_by": true, + "following": true, + "can_dm": true, + "can_media_tag": true, + "created_at": "Sat May 16 15:20:01 +0000 2009", + "default_profile": false, + "default_profile_image": false, + "description": "OpenTween Project 言い出しっぺ", + "entities": { + "description": { + "urls": [] + }, + "url": { + "urls": [ + { + "display_url": "m.upsilo.net/@upsilon", + "expanded_url": "https://m.upsilo.net/@upsilon", + "url": "https://t.co/vNMmyHHOQD", + "indices": [ + 0, + 23 + ] + } + ] + } + }, + "fast_followers_count": 0, + "favourites_count": 216272, + "followers_count": 1301, + "friends_count": 917, + "has_custom_timelines": false, + "is_translator": false, + "listed_count": 92, + "location": "Funabashi, Chiba, Japan", + "media_count": 1040, + "name": "upsilon", + "normal_followers_count": 1301, + "pinned_tweet_ids_str": [], + "possibly_sensitive": false, + "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016", + "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png", + "profile_interstitial_type": "", + "screen_name": "kim_upsilon", + "statuses_count": 10096, + "translator_type": "regular", + "url": "https://t.co/vNMmyHHOQD", + "verified": false, + "want_retweets": true, + "withheld_in_countries": [] + } + } + } + }, + "edit_control": { + "edit_tweet_ids": [ + "1614587968567783424" + ], + "editable_until_msecs": "1673784543000", + "is_edit_eligible": true, + "edits_remaining": "5" + }, + "edit_perspective": { + "favorited": false, + "retweeted": false + }, + "is_translatable": true, + "views": { + "count": "513", + "state": "EnabledWithCount" + }, + "source": "OpenTween (dev)", + "legacy": { + "bookmark_count": 0, + "bookmarked": false, + "created_at": "Sun Jan 15 11:39:03 +0000 2023", + "conversation_id_str": "1614587968567783424", + "display_text_range": [ + 0, + 3 + ], + "entities": { + "media": [ + { + "display_url": "pic.twitter.com/nMKqMc02ps", + "expanded_url": "https://twitter.com/kim_upsilon/status/1614587968567783424/photo/1", + "id_str": "1614587909176426497", + "indices": [ + 4, + 27 + ], + "media_url_https": "https://pbs.twimg.com/media/FmgrJiEaAAEU42G.png", + "type": "photo", + "url": "https://t.co/nMKqMc02ps", + "features": { + "large": { + "faces": [] + }, + "medium": { + "faces": [] + }, + "small": { + "faces": [] + }, + "orig": { + "faces": [] + } + }, + "sizes": { + "large": { + "h": 736, + "w": 1170, + "resize": "fit" + }, + "medium": { + "h": 736, + "w": 1170, + "resize": "fit" + }, + "small": { + "h": 428, + "w": 680, + "resize": "fit" + }, + "thumb": { + "h": 150, + "w": 150, + "resize": "crop" + } + }, + "original_info": { + "height": 736, + "width": 1170, + "focus_rects": [ + { + "x": 0, + "y": 0, + "w": 1170, + "h": 655 + }, + { + "x": 0, + "y": 0, + "w": 736, + "h": 736 + }, + { + "x": 0, + "y": 0, + "w": 646, + "h": 736 + }, + { + "x": 20, + "y": 0, + "w": 368, + "h": 736 + }, + { + "x": 0, + "y": 0, + "w": 1170, + "h": 736 + } + ] + } + }, + { + "display_url": "pic.twitter.com/nMKqMc02ps", + "expanded_url": "https://twitter.com/kim_upsilon/status/1614587968567783424/photo/1", + "id_str": "1614587909256130561", + "indices": [ + 4, + 27 + ], + "media_url_https": "https://pbs.twimg.com/media/FmgrJiXaMAEu873.jpg", + "type": "photo", + "url": "https://t.co/nMKqMc02ps", + "features": { + "large": { + "faces": [ + { + "x": 1432, + "y": 192, + "h": 104, + "w": 104 + } + ] + }, + "medium": { + "faces": [ + { + "x": 1010, + "y": 135, + "h": 73, + "w": 73 + } + ] + }, + "small": { + "faces": [ + { + "x": 572, + "y": 76, + "h": 41, + "w": 41 + } + ] + }, + "orig": { + "faces": [ + { + "x": 1432, + "y": 192, + "h": 104, + "w": 104 + } + ] + } + }, + "sizes": { + "large": { + "h": 1195, + "w": 1700, + "resize": "fit" + }, + "medium": { + "h": 844, + "w": 1200, + "resize": "fit" + }, + "small": { + "h": 478, + "w": 680, + "resize": "fit" + }, + "thumb": { + "h": 150, + "w": 150, + "resize": "crop" + } + }, + "original_info": { + "height": 1195, + "width": 1700, + "focus_rects": [ + { + "x": 0, + "y": 0, + "w": 1700, + "h": 952 + }, + { + "x": 380, + "y": 0, + "w": 1195, + "h": 1195 + }, + { + "x": 453, + "y": 0, + "w": 1048, + "h": 1195 + }, + { + "x": 678, + "y": 0, + "w": 598, + "h": 1195 + }, + { + "x": 0, + "y": 0, + "w": 1700, + "h": 1195 + } + ] + } + } + ], + "user_mentions": [], + "urls": [], + "hashtags": [], + "symbols": [] + }, + "extended_entities": { + "media": [ + { + "display_url": "pic.twitter.com/nMKqMc02ps", + "expanded_url": "https://twitter.com/kim_upsilon/status/1614587968567783424/photo/1", + "ext_alt_text": "OpenTweenで @opentween のツイート一覧を表示しているスクショ", + "id_str": "1614587909176426497", + "indices": [ + 4, + 27 + ], + "media_key": "3_1614587909176426497", + "media_url_https": "https://pbs.twimg.com/media/FmgrJiEaAAEU42G.png", + "type": "photo", + "url": "https://t.co/nMKqMc02ps", + "ext_media_availability": { + "status": "Available" + }, + "features": { + "large": { + "faces": [] + }, + "medium": { + "faces": [] + }, + "small": { + "faces": [] + }, + "orig": { + "faces": [] + } + }, + "sizes": { + "large": { + "h": 736, + "w": 1170, + "resize": "fit" + }, + "medium": { + "h": 736, + "w": 1170, + "resize": "fit" + }, + "small": { + "h": 428, + "w": 680, + "resize": "fit" + }, + "thumb": { + "h": 150, + "w": 150, + "resize": "crop" + } + }, + "original_info": { + "height": 736, + "width": 1170, + "focus_rects": [ + { + "x": 0, + "y": 0, + "w": 1170, + "h": 655 + }, + { + "x": 0, + "y": 0, + "w": 736, + "h": 736 + }, + { + "x": 0, + "y": 0, + "w": 646, + "h": 736 + }, + { + "x": 20, + "y": 0, + "w": 368, + "h": 736 + }, + { + "x": 0, + "y": 0, + "w": 1170, + "h": 736 + } + ] + } + }, + { + "display_url": "pic.twitter.com/nMKqMc02ps", + "expanded_url": "https://twitter.com/kim_upsilon/status/1614587968567783424/photo/1", + "ext_alt_text": "OpenTweenの新しい画像投稿画面を動かしている様子のスクショ", + "id_str": "1614587909256130561", + "indices": [ + 4, + 27 + ], + "media_key": "3_1614587909256130561", + "media_url_https": "https://pbs.twimg.com/media/FmgrJiXaMAEu873.jpg", + "type": "photo", + "url": "https://t.co/nMKqMc02ps", + "ext_media_availability": { + "status": "Available" + }, + "features": { + "large": { + "faces": [ + { + "x": 1432, + "y": 192, + "h": 104, + "w": 104 + } + ] + }, + "medium": { + "faces": [ + { + "x": 1010, + "y": 135, + "h": 73, + "w": 73 + } + ] + }, + "small": { + "faces": [ + { + "x": 572, + "y": 76, + "h": 41, + "w": 41 + } + ] + }, + "orig": { + "faces": [ + { + "x": 1432, + "y": 192, + "h": 104, + "w": 104 + } + ] + } + }, + "sizes": { + "large": { + "h": 1195, + "w": 1700, + "resize": "fit" + }, + "medium": { + "h": 844, + "w": 1200, + "resize": "fit" + }, + "small": { + "h": 478, + "w": 680, + "resize": "fit" + }, + "thumb": { + "h": 150, + "w": 150, + "resize": "crop" + } + }, + "original_info": { + "height": 1195, + "width": 1700, + "focus_rects": [ + { + "x": 0, + "y": 0, + "w": 1700, + "h": 952 + }, + { + "x": 380, + "y": 0, + "w": 1195, + "h": 1195 + }, + { + "x": 453, + "y": 0, + "w": 1048, + "h": 1195 + }, + { + "x": 678, + "y": 0, + "w": 598, + "h": 1195 + }, + { + "x": 0, + "y": 0, + "w": 1700, + "h": 1195 + } + ] + } + } + ] + }, + "favorite_count": 4, + "favorited": false, + "full_text": "てすと https://t.co/nMKqMc02ps", + "is_quote_status": false, + "lang": "ja", + "possibly_sensitive": false, + "possibly_sensitive_editable": true, + "quote_count": 0, + "reply_count": 0, + "retweet_count": 0, + "retweeted": false, + "user_id_str": "40480664", + "id_str": "1614587968567783424" + } + } + }, + "tweetDisplayType": "Tweet" +} diff --git a/OpenTween.Tests/ShortUrlTest.cs b/OpenTween.Tests/ShortUrlTest.cs index 887222617..b5402176c 100644 --- a/OpenTween.Tests/ShortUrlTest.cs +++ b/OpenTween.Tests/ShortUrlTest.cs @@ -370,8 +370,8 @@ public async Task ExpandUrlHtmlAsync_Test() return this.CreateRedirectResponse("http://example.com/hoge2"); }); - Assert.Equal("hogehoge", - await shortUrl.ExpandUrlHtmlAsync("hogehoge")); + Assert.Equal("""hogehoge""", + await shortUrl.ExpandUrlHtmlAsync("""hogehoge""")); Assert.Equal(0, handler.QueueCount); } @@ -390,8 +390,8 @@ public async Task ExpandUrlHtmlAsync_RelativeUriTest() return this.CreateRedirectResponse("http://example.com/hoge"); }); - Assert.Equal("hogehoge", - await shortUrl.ExpandUrlHtmlAsync("hogehoge")); + Assert.Equal("""hogehoge""", + await shortUrl.ExpandUrlHtmlAsync("""hogehoge""")); Assert.Equal(1, handler.QueueCount); } diff --git a/OpenTween.Tests/Thumbnail/Services/FoursquareCheckinTest.cs b/OpenTween.Tests/Thumbnail/Services/FoursquareCheckinTest.cs index 6d8bb1f82..37c92c702 100644 --- a/OpenTween.Tests/Thumbnail/Services/FoursquareCheckinTest.cs +++ b/OpenTween.Tests/Thumbnail/Services/FoursquareCheckinTest.cs @@ -221,23 +221,25 @@ public async Task GetThumbnailInfoAsync_ApiKeyErrorTest() [Fact] public void ParseInLocation_Test() { - var json = @"{ - ""meta"": { ""code"": 200 }, - ""response"": { - ""checkin"": { - ""id"": ""xxxxxxxxx"", - ""type"": ""checkin"", - ""venue"": { - ""id"": ""4b73dedcf964a5206bbe2de3"", - ""name"": ""高松駅 (Takamatsu Sta.)"", - ""location"": { - ""lat"": 34.35067978344854, - ""lng"": 134.04693603515625 - } - } - } - } -}"; + var json = """ + { + "meta": { "code": 200 }, + "response": { + "checkin": { + "id": "xxxxxxxxx", + "type": "checkin", + "venue": { + "id": "4b73dedcf964a5206bbe2de3", + "name": "高松駅 (Takamatsu Sta.)", + "location": { + "lat": 34.35067978344854, + "lng": 134.04693603515625 + } + } + } + } + } + """; var jsonBytes = Encoding.UTF8.GetBytes(json); var location = FoursquareCheckin.ParseIntoLocation(jsonBytes); @@ -249,23 +251,25 @@ public void ParseInLocation_Test() [Fact] public void ParseInLocation_CultureTest() { - var json = @"{ - ""meta"": { ""code"": 200 }, - ""response"": { - ""checkin"": { - ""id"": ""xxxxxxxxx"", - ""type"": ""checkin"", - ""venue"": { - ""id"": ""4b73dedcf964a5206bbe2de3"", - ""name"": ""高松駅 (Takamatsu Sta.)"", - ""location"": { - ""lat"": 34.35067978344854, - ""lng"": 134.04693603515625 - } - } - } - } -}"; + var json = """ + { + "meta": { "code": 200 }, + "response": { + "checkin": { + "id": "xxxxxxxxx", + "type": "checkin", + "venue": { + "id": "4b73dedcf964a5206bbe2de3", + "name": "高松駅 (Takamatsu Sta.)", + "location": { + "lat": 34.35067978344854, + "lng": 134.04693603515625 + } + } + } + } + } + """; var origCulture = Thread.CurrentThread.CurrentCulture; Thread.CurrentThread.CurrentCulture = new CultureInfo("ru-RU"); @@ -282,24 +286,26 @@ public void ParseInLocation_CultureTest() [Fact] public void ParseInLocation_PlanetTest() { - var json = @"{ - ""meta"": { ""code"": 200 }, - ""response"": { - ""checkin"": { - ""id"": ""xxxxxxxxx"", - ""type"": ""checkin"", - ""venue"": { - ""id"": ""5069d8bdc640385aa7711fe4"", - ""name"": ""Gale Crater"", - ""location"": { - ""planet"": ""mars"", - ""lat"": 34.201694, - ""lng"": -118.17166 - } - } - } - } -}"; + var json = """ + { + "meta": { "code": 200 }, + "response": { + "checkin": { + "id": "xxxxxxxxx", + "type": "checkin", + "venue": { + "id": "5069d8bdc640385aa7711fe4", + "name": "Gale Crater", + "location": { + "planet": "mars", + "lat": 34.201694, + "lng": -118.17166 + } + } + } + } + } + """; var jsonBytes = Encoding.UTF8.GetBytes(json); var location = FoursquareCheckin.ParseIntoLocation(jsonBytes); @@ -310,16 +316,18 @@ public void ParseInLocation_PlanetTest() [Fact] public void ParseInLocation_VenueNullTest() { - var json = @"{ - ""meta"": { ""code"": 200 }, - ""response"": { - ""checkin"": { - ""id"": ""xxxxxxxxx"", - ""type"": ""checkin"", - ""venue"": null - } - } -}"; + var json = """ + { + "meta": { "code": 200 }, + "response": { + "checkin": { + "id": "xxxxxxxxx", + "type": "checkin", + "venue": null + } + } + } + """; var jsonBytes = Encoding.UTF8.GetBytes(json); var location = FoursquareCheckin.ParseIntoLocation(jsonBytes); diff --git a/OpenTween.Tests/Thumbnail/Services/ImgAzyobuziNetTest.cs b/OpenTween.Tests/Thumbnail/Services/ImgAzyobuziNetTest.cs index 6031fbed9..45283518b 100644 --- a/OpenTween.Tests/Thumbnail/Services/ImgAzyobuziNetTest.cs +++ b/OpenTween.Tests/Thumbnail/Services/ImgAzyobuziNetTest.cs @@ -62,12 +62,12 @@ protected override Task FetchRegexAsync(string apiBase) throw new HttpRequestException(); if (apiBase == "http://error.example.com/api/") - return Encoding.UTF8.GetBytes("{\"error\": {\"code\": 5001}}"); + return Encoding.UTF8.GetBytes("""{"error": {"code": 5001}}"""); if (apiBase == "http://invalid.example.com/api/") return Encoding.UTF8.GetBytes("<<>>"); - return Encoding.UTF8.GetBytes("[{\"name\": \"hogehoge\", \"regex\": \"^https?://example.com/(.+)$\"}]"); + return Encoding.UTF8.GetBytes("""[{"name": "hogehoge", "regex": "^https?://example.com/(.+)$"}]"""); }); } } diff --git a/OpenTween.Tests/Thumbnail/Services/MetaThumbnailServiceTest.cs b/OpenTween.Tests/Thumbnail/Services/MetaThumbnailServiceTest.cs index 2e30ba675..5b458e5b0 100644 --- a/OpenTween.Tests/Thumbnail/Services/MetaThumbnailServiceTest.cs +++ b/OpenTween.Tests/Thumbnail/Services/MetaThumbnailServiceTest.cs @@ -54,19 +54,19 @@ public async Task OGPMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - - - - - - hogehoge - - -

hogehoge

- - -"; + service.FakeHtml = """ + + + + + + hogehoge + + +

hogehoge

+ + + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -80,15 +80,15 @@ public async Task TwitterMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - -hogehoge + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -102,15 +102,15 @@ public async Task InvalidMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - -hogehoge + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -124,15 +124,15 @@ public async Task ReverseMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - -hogehoge + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -146,16 +146,16 @@ public async Task BadMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - - -hogehoge + + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -169,15 +169,15 @@ public async Task BadMetaOneLineTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - -hogehoge + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -191,15 +191,15 @@ public async Task ReverseMetaOneLineTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - - -hogehoge + + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -213,14 +213,14 @@ public async Task NoMetaTest() { var service = new TestMetaThumbnailService(@"http://example.com/.+"); - service.FakeHtml = @" - + service.FakeHtml = """ + - -hogehoge + + hogehoge -

hogehoge -"; +

hogehoge + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://example.com/abcd", new PostClass(), CancellationToken.None); Assert.Null(thumbinfo); diff --git a/OpenTween.Tests/Thumbnail/Services/TinamiTest.cs b/OpenTween.Tests/Thumbnail/Services/TinamiTest.cs index d2d1c9a46..5a6db06fb 100644 --- a/OpenTween.Tests/Thumbnail/Services/TinamiTest.cs +++ b/OpenTween.Tests/Thumbnail/Services/TinamiTest.cs @@ -56,21 +56,23 @@ public async Task ApiTest() { var service = new TestTinami(); - service.FakeXml = @" - - - ほげほげ - 説明 - - - - - http://img.tinami.com/hogehoge_full.gif - 640 - 480 - - -"; + service.FakeXml = """ + + + + ほげほげ + 説明 + + + + + http://img.tinami.com/hogehoge_full.gif + 640 + 480 + + + + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://www.tinami.com/view/12345", new PostClass(), CancellationToken.None); Assert.NotNull(thumbinfo); @@ -84,10 +86,12 @@ public async Task ApiErrorTest() { var service = new TestTinami(); - service.FakeXml = @" - - -"; + service.FakeXml = """ + + + + + """; var thumbinfo = await service.GetThumbnailInfoAsync("http://www.tinami.com/view/12345", new PostClass(), CancellationToken.None); Assert.Null(thumbinfo); diff --git a/OpenTween.Tests/Thumbnail/Services/TumblrTest.cs b/OpenTween.Tests/Thumbnail/Services/TumblrTest.cs index e31155dce..3ff5aba00 100644 --- a/OpenTween.Tests/Thumbnail/Services/TumblrTest.cs +++ b/OpenTween.Tests/Thumbnail/Services/TumblrTest.cs @@ -17,31 +17,33 @@ public class TumblrTest [Fact] public void ParsePostJson_Test() { - var json = @"{ - ""meta"": { ""status"": 200, ""msg"": ""OK"" }, - ""response"": { - ""blog"": { }, - ""posts"": [ - { - ""id"": 1234567, - ""post_url"": ""http://example.com/post/1234567"", - ""type"": ""photo"", - ""photos"": [ - { - ""caption"": """", - ""alt_sizes"": [ - { - ""width"": 1280, - ""height"": 722, - ""url"": ""http://example.com/photo/1280/1234567/1/tumblr_hogehoge"" - } - ] - } - ] - } - ] - } -}"; + var json = """ + { + "meta": { "status": 200, "msg": "OK" }, + "response": { + "blog": { }, + "posts": [ + { + "id": 1234567, + "post_url": "http://example.com/post/1234567", + "type": "photo", + "photos": [ + { + "caption": "", + "alt_sizes": [ + { + "width": 1280, + "height": 722, + "url": "http://example.com/photo/1280/1234567/1/tumblr_hogehoge" + } + ] + } + ] + } + ] + } + } + """; var jsonBytes = Encoding.UTF8.GetBytes(json); var thumbs = Tumblr.ParsePhotoPostJson(jsonBytes); @@ -75,10 +77,12 @@ public async Task GetThumbnailInfoAsync_RequestTest() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(@"{ - ""meta"": { ""status"": 200, ""msg"": ""OK"" }, - ""response"": { ""blog"": { }, ""posts"": { } } -}"), + Content = new StringContent(""" + { + "meta": { "status": 200, "msg": "OK" }, + "response": { "blog": { }, "posts": { } } + } + """), }; }); @@ -112,10 +116,12 @@ public async Task GetThumbnailInfoAsync_CustomHostnameRequestTest() return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(@"{ - ""meta"": { ""status"": 200, ""msg"": ""OK"" }, - ""response"": { ""blog"": { }, ""posts"": { } } -}"), + Content = new StringContent(""" + { + "meta": { "status": 200, "msg": "OK" }, + "response": { "blog": { }, "posts": { } } + } + """), }; }); diff --git a/OpenTween.Tests/TimelineListViewCacheTest.cs b/OpenTween.Tests/TimelineListViewCacheTest.cs index cd701866d..6b88e5d2c 100644 --- a/OpenTween.Tests/TimelineListViewCacheTest.cs +++ b/OpenTween.Tests/TimelineListViewCacheTest.cs @@ -34,7 +34,7 @@ private PostClass CreatePost() { return new() { - StatusId = this.random.Next(10000), + StatusId = new TwitterStatusId(this.random.Next(10000)), UserId = this.random.Next(10000), ScreenName = "test", Nickname = "てすと", @@ -151,9 +151,11 @@ public void GetItem_RetweetTest() using var listView = new DetailsListView(); using var cache = new TimelineListViewCache(listView, tab, new()); - var post = this.CreatePost(); - post.RetweetedId = 50L; - post.RetweetedBy = "hoge"; + var post = this.CreatePost() with + { + RetweetedId = new TwitterStatusId("50"), + RetweetedBy = "hoge", + }; tab.AddPostQueue(post); tab.AddSubmit(); @@ -313,8 +315,10 @@ public void GetStyle_ForeColor_RetweetTest() using var listView = new DetailsListView(); using var cache = new TimelineListViewCache(listView, tab, new()); - var post = this.CreatePost(); - post.RetweetedId = 100L; + var post = this.CreatePost() with + { + RetweetedId = new TwitterStatusId("100"), + }; tab.AddPostQueue(post); tab.AddSubmit(); @@ -368,9 +372,11 @@ public void GetStyle_ForeColor_DMTest() using var listView = new DetailsListView(); using var cache = new TimelineListViewCache(listView, tab, settingCommon); - var post = this.CreatePost(); - post.IsDm = true; - post.IsOwl = true; + var post = this.CreatePost() with + { + IsDm = true, + IsOwl = true, + }; // DM の場合は設定に関わらず ColorOWL を使う settingCommon.OneWayLove = false; @@ -392,8 +398,10 @@ public void GetStyle_BackColor_AtToTest() var targetPost = this.CreatePost(); tab.AddPostQueue(targetPost); - var basePost = this.CreatePost(); - basePost.InReplyToStatusId = targetPost.StatusId; + var basePost = this.CreatePost() with + { + InReplyToStatusId = targetPost.StatusId, + }; tab.AddPostQueue(basePost); tab.AddSubmit(); @@ -410,8 +418,10 @@ public void GetStyle_BackColor_SelfTest() using var listView = new DetailsListView(); using var cache = new TimelineListViewCache(listView, tab, new()); - var targetPost = this.CreatePost(); - targetPost.IsMe = true; + var targetPost = this.CreatePost() with + { + IsMe = true, + }; tab.AddPostQueue(targetPost); var basePost = this.CreatePost(); @@ -431,8 +441,10 @@ public void GetStyle_BackColor_AtSelfTest() using var listView = new DetailsListView(); using var cache = new TimelineListViewCache(listView, tab, new()); - var targetPost = this.CreatePost(); - targetPost.IsReply = true; + var targetPost = this.CreatePost() with + { + IsReply = true, + }; tab.AddPostQueue(targetPost); var basePost = this.CreatePost(); @@ -455,8 +467,10 @@ public void GetStyle_BackColor_AtFromTargetTest() var targetPost = this.CreatePost(); tab.AddPostQueue(targetPost); - var basePost = this.CreatePost(); - basePost.ReplyToList = new() { (targetPost.UserId, targetPost.ScreenName) }; + var basePost = this.CreatePost() with + { + ReplyToList = new() { (targetPost.UserId, targetPost.ScreenName) }, + }; tab.AddPostQueue(basePost); tab.AddSubmit(); @@ -476,8 +490,10 @@ public void GetStyle_BackColor_AtTargetTest() var basePost = this.CreatePost(); tab.AddPostQueue(basePost); - var targetPost = this.CreatePost(); - targetPost.ReplyToList = new() { (basePost.UserId, basePost.ScreenName) }; + var targetPost = this.CreatePost() with + { + ReplyToList = new() { (basePost.UserId, basePost.ScreenName) }, + }; tab.AddPostQueue(targetPost); tab.AddSubmit(); @@ -497,8 +513,10 @@ public void GetStyle_BackColor_TargetTest() var targetPost = this.CreatePost(); tab.AddPostQueue(targetPost); - var basePost = this.CreatePost(); - basePost.UserId = targetPost.UserId; + var basePost = this.CreatePost() with + { + UserId = targetPost.UserId, + }; tab.AddPostQueue(basePost); tab.AddSubmit(); diff --git a/OpenTween.Tests/TweenMainTest.cs b/OpenTween.Tests/TweenMainTest.cs index 275074c1e..a7974618c 100644 --- a/OpenTween.Tests/TweenMainTest.cs +++ b/OpenTween.Tests/TweenMainTest.cs @@ -78,7 +78,7 @@ public void GetUrlFromDataObject_UnknownFormatTest() [Fact] public void CreateRetweetUnofficial_UrlTest() { - var statusText = "twitter.com"; + var statusText = """twitter.com"""; Assert.Equal("http://twitter.com/", TweenMain.CreateRetweetUnofficial(statusText, false)); } @@ -86,7 +86,7 @@ public void CreateRetweetUnofficial_UrlTest() [Fact] public void CreateRetweetUnofficial_MentionTest() { - var statusText = "@TwitterAPI"; + var statusText = """@TwitterAPI"""; Assert.Equal("@TwitterAPI", TweenMain.CreateRetweetUnofficial(statusText, false)); } @@ -94,7 +94,7 @@ public void CreateRetweetUnofficial_MentionTest() [Fact] public void CreateRetweetUnofficial_HashtagTest() { - var statusText = "#OpenTween"; + var statusText = """#OpenTween"""; Assert.Equal("#OpenTween", TweenMain.CreateRetweetUnofficial(statusText, false)); } diff --git a/OpenTween.Tests/TweetDetailsViewTest.cs b/OpenTween.Tests/TweetDetailsViewTest.cs index 5164b2ff4..6aeceea2c 100644 --- a/OpenTween.Tests/TweetDetailsViewTest.cs +++ b/OpenTween.Tests/TweetDetailsViewTest.cs @@ -37,18 +37,18 @@ public void FormatQuoteTweetHtml_PostClassTest() { var post = new PostClass { - StatusId = 12345L, + StatusId = new TwitterStatusId("12345"), Nickname = "upsilon", ScreenName = "kim_upsilon", - Text = "@twitterapi hogehoge", + Text = """@twitterapi hogehoge""", CreatedAt = new DateTimeUtc(2015, 3, 30, 3, 30, 0), }; // PostClass.Text はリンクを除去するのみでエスケープは行わない // (TweetFormatter によって既にエスケープされた文字列が格納されているため) - var expected = "" + - "

" + + var expected = """""" + + """
""" + "

@twitterapi hogehoge

— upsilon (@kim_upsilon) " + DateTimeUtc.Parse("2015/03/30 3:30:00", DateTimeFormatInfo.InvariantInfo).ToLocalTimeString() + "
"; Assert.Equal(expected, TweetDetailsView.FormatQuoteTweetHtml(post, isReply: false)); @@ -57,11 +57,11 @@ public void FormatQuoteTweetHtml_PostClassTest() [Fact] public void FormatQuoteTweetHtml_HtmlTest() { - var statusId = 12345L; // リンク先のステータスID + var statusId = new TwitterStatusId("12345"); // リンク先のステータスID var html = "hogehoge"; // HTMLをそのまま出力する (エスケープしない) - var expected = "" + - "
hogehoge
" + + var expected = """
""" + + """
hogehoge
""" + "
"; Assert.Equal(expected, TweetDetailsView.FormatQuoteTweetHtml(statusId, html, isReply: false)); } @@ -70,16 +70,16 @@ public void FormatQuoteTweetHtml_HtmlTest() public void FormatQuoteTweetHtml_ReplyHtmlTest() { // blockquote の class に reply が付与される - var expected = "" + - "
hogehoge
" + + var expected = """
""" + + """
hogehoge
""" + "
"; - Assert.Equal(expected, TweetDetailsView.FormatQuoteTweetHtml(12345L, "hogehoge", isReply: true)); + Assert.Equal(expected, TweetDetailsView.FormatQuoteTweetHtml(new TwitterStatusId("12345"), "hogehoge", isReply: true)); } [Fact] public void StripLinkTagHtml_Test() { - var html = "@twitterapi"; + var html = """@twitterapi"""; var expected = "@twitterapi"; Assert.Equal(expected, TweetDetailsView.StripLinkTagHtml(html)); diff --git a/OpenTween.Tests/TweetFormatterTest.cs b/OpenTween.Tests/TweetFormatterTest.cs index 8902c96a5..49a417e1b 100644 --- a/OpenTween.Tests/TweetFormatterTest.cs +++ b/OpenTween.Tests/TweetFormatterTest.cs @@ -46,7 +46,7 @@ public void FormatUrlEntity_Test() }, }; - var expected = "example.com"; + var expected = """example.com"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -66,7 +66,7 @@ public void FormatUrlEntity_TwitterComTest() }; // twitter.com 宛のリンクは t.co を経由せずにリンクする - var expected = "twitter.com/twitterapi"; + var expected = """twitter.com/twitterapi"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -83,7 +83,7 @@ public void FormatHashtagEntity_Test() }, }; - var expected = "#OpenTween"; + var expected = """#OpenTween"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -102,7 +102,7 @@ public void FormatMentionEntity_Test() }, }; - var expected = "@TwitterAPI"; + var expected = """@TwitterAPI"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -131,7 +131,7 @@ public void FormatMediaEntity_Test() }, }; - var expected = "pic.twitter.com/h5dCr4ftN4"; + var expected = """pic.twitter.com/h5dCr4ftN4"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -161,7 +161,7 @@ public void FormatMediaEntity_AltTextTest() }, }; - var expected = "pic.twitter.com/h5dCr4ftN4"; + var expected = """pic.twitter.com/h5dCr4ftN4"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -179,7 +179,7 @@ public void FormatEmojiEntity_Test() }, }; - var expected = "\"🍣\""; + var expected = """🍣"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -254,7 +254,7 @@ public void AutoLinkHtml_EscapeTest() }, }; - var expected = ""'@twitterapi'""; + var expected = """"'@twitterapi'""""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -274,7 +274,7 @@ public void AutoLinkHtml_EscapeTest2() }, }; - var expected = "<b> @twitterapi </b>"; + var expected = """<b> @twitterapi </b>"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -294,7 +294,7 @@ public void AutoLinkHtml_EscapeTest3() }, }; - var expected = "<b> @twitterapi </b>"; + var expected = """<b> @twitterapi </b>"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -312,7 +312,7 @@ public void AutoLinkHtml_EscapeUrlTest() }, }; - var expected = "#ぜんぶ雪のせいだ"; + var expected = """#ぜんぶ雪のせいだ"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -332,7 +332,7 @@ public void AutoLinkHtml_SurrogatePairTest() }, }; - var expected = "🐬🐬 @irucame 🐬🐬"; + var expected = """🐬🐬 @irucame 🐬🐬"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -355,8 +355,8 @@ public void AutoLinkHtml_SurrogatePairTest2() }, }; - var expected = "🐬🐬 #🐬🐬 " + - "🐬🐬 #🐬🐬 🐬🐬"; + var expected = """🐬🐬 #🐬🐬 """ + + """🐬🐬 #🐬🐬 🐬🐬"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -375,7 +375,7 @@ public void AutoLinkHtml_CompositeCharacterTest() }, }; - var expected = "Caf\u00e9 #test"; + var expected = "Caf\u00e9 " + """#test"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -394,7 +394,7 @@ public void AutoLinkHtml_CombiningCharacterSequenceTest() }, }; - var expected = "Cafe\u0301 #test"; + var expected = "Cafe\u0301 " + """#test"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } @@ -462,7 +462,7 @@ public void AutoLinkHtml_OverlappedEntitiesTest() }; var expected = ""I hope you'll keep...building bonds of friendship that will enrich your lives & enrich our world" \u2014FLOTUS in China, " + - "pic.twitter.com/fxmuQN9JL9"; + """pic.twitter.com/fxmuQN9JL9"""; Assert.Equal(expected, TweetFormatter.AutoLinkHtml(text, entities)); } } diff --git a/OpenTween.Tests/TwitterTest.cs b/OpenTween.Tests/TwitterTest.cs index 2c03f9698..56bdf6c52 100644 --- a/OpenTween.Tests/TwitterTest.cs +++ b/OpenTween.Tests/TwitterTest.cs @@ -38,10 +38,10 @@ public class TwitterTest [Theory] [InlineData("https://twitter.com/twitterapi/status/22634515958", new[] { "22634515958" })] - [InlineData("twitter.com/twitterapi/stat…", + [InlineData("""twitter.com/twitterapi/stat…""", new[] { "22634515958" })] - [InlineData("https://t.co/bU3oR95KIy" + - "https://t.co/bbbbbbbb", + [InlineData("""https://t.co/bU3oR95KIy""" + + """https://t.co/bbbbbbbb""", new[] { "224782458816692224", "311081657790771200" })] [InlineData("https://mobile.twitter.com/muji_net/status/21984934471", new[] { "21984934471" })] @@ -86,26 +86,26 @@ public void ThirdPartyStatusUrlRegexTest(string url, string[] expected) [Fact] public void FindTopOfReplyChainTest() { - var posts = new Dictionary + var posts = new Dictionary { - [950L] = new PostClass { StatusId = 950L, InReplyToStatusId = null }, // このツイートが末端 - [987L] = new PostClass { StatusId = 987L, InReplyToStatusId = 950L }, - [999L] = new PostClass { StatusId = 999L, InReplyToStatusId = 987L }, - [1000L] = new PostClass { StatusId = 1000L, InReplyToStatusId = 999L }, + [new TwitterStatusId("950")] = new PostClass { StatusId = new TwitterStatusId("950"), InReplyToStatusId = null }, // このツイートが末端 + [new TwitterStatusId("987")] = new PostClass { StatusId = new TwitterStatusId("987"), InReplyToStatusId = new TwitterStatusId("950") }, + [new TwitterStatusId("999")] = new PostClass { StatusId = new TwitterStatusId("999"), InReplyToStatusId = new TwitterStatusId("987") }, + [new TwitterStatusId("1000")] = new PostClass { StatusId = new TwitterStatusId("1000"), InReplyToStatusId = new TwitterStatusId("999") }, }; - Assert.Equal(950L, Twitter.FindTopOfReplyChain(posts, 1000L).StatusId); - Assert.Equal(950L, Twitter.FindTopOfReplyChain(posts, 950L).StatusId); - Assert.Throws(() => Twitter.FindTopOfReplyChain(posts, 500L)); + Assert.Equal(new TwitterStatusId("950"), Twitter.FindTopOfReplyChain(posts, new TwitterStatusId("1000")).StatusId); + Assert.Equal(new TwitterStatusId("950"), Twitter.FindTopOfReplyChain(posts, new TwitterStatusId("950")).StatusId); + Assert.Throws(() => Twitter.FindTopOfReplyChain(posts, new TwitterStatusId("500"))); - posts = new Dictionary + posts = new Dictionary { - // 1200L は posts の中に存在しない - [1210L] = new PostClass { StatusId = 1210L, InReplyToStatusId = 1200L }, - [1220L] = new PostClass { StatusId = 1220L, InReplyToStatusId = 1210L }, - [1230L] = new PostClass { StatusId = 1230L, InReplyToStatusId = 1220L }, + // new TwitterStatusId("1200") は posts の中に存在しない + [new TwitterStatusId("1210")] = new PostClass { StatusId = new TwitterStatusId("1210"), InReplyToStatusId = new TwitterStatusId("1200") }, + [new TwitterStatusId("1220")] = new PostClass { StatusId = new TwitterStatusId("1220"), InReplyToStatusId = new TwitterStatusId("1210") }, + [new TwitterStatusId("1230")] = new PostClass { StatusId = new TwitterStatusId("1230"), InReplyToStatusId = new TwitterStatusId("1220") }, }; - Assert.Equal(1210L, Twitter.FindTopOfReplyChain(posts, 1230L).StatusId); - Assert.Equal(1210L, Twitter.FindTopOfReplyChain(posts, 1210L).StatusId); + Assert.Equal(new TwitterStatusId("1210"), Twitter.FindTopOfReplyChain(posts, new TwitterStatusId("1230")).StatusId); + Assert.Equal(new TwitterStatusId("1210"), Twitter.FindTopOfReplyChain(posts, new TwitterStatusId("1210")).StatusId); } [Fact] diff --git a/OpenTween/Api/DataModel/TwitterUser.cs b/OpenTween/Api/DataModel/TwitterUser.cs index dfb9a4550..a1597d05d 100644 --- a/OpenTween/Api/DataModel/TwitterUser.cs +++ b/OpenTween/Api/DataModel/TwitterUser.cs @@ -115,5 +115,17 @@ public class TwitterUserEntity /// public static TwitterUser ParseJson(string json) => MyCommon.CreateDataFromJson(json); + + public static TwitterUser CreateUnknownUser() + { + return new() + { + Id = 0L, + IdStr = "0", + ScreenName = "?????", + Name = "Unknown User", + ProfileImageUrlHttps = "", + }; + } } } diff --git a/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs new file mode 100644 index 000000000..ce8532b32 --- /dev/null +++ b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs @@ -0,0 +1,90 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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.Linq; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; +using OpenTween.Connection; + +namespace OpenTween.Api.GraphQL +{ + public class ListLatestTweetsTimelineRequest + { + private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/6ClPnsuzQJ1p7-g32GQw9Q/ListLatestTweetsTimeline"); + + public string ListId { get; set; } + + public int Count { get; set; } = 20; + + public ListLatestTweetsTimelineRequest(string listId) + => this.ListId = listId; + + public Dictionary CreateParameters() + { + return new() + { + ["variables"] = "{" + + $@"""listId"":""{JsonUtils.EscapeJsonString(this.ListId)}""," + + $@"""count"":{this.Count}" + + "}", + ["features"] = "{" + + @"""rweb_lists_timeline_redesign_enabled"":true," + + @"""responsive_web_graphql_exclude_directive_enabled"":true," + + @"""verified_phone_label_enabled"":false," + + @"""creator_subscriptions_tweet_preview_api_enabled"":true," + + @"""responsive_web_graphql_timeline_navigation_enabled"":true," + + @"""responsive_web_graphql_skip_user_profile_image_extensions_enabled"":false," + + @"""tweetypie_unmention_optimization_enabled"":true," + + @"""responsive_web_edit_tweet_api_enabled"":true," + + @"""graphql_is_translatable_rweb_tweet_is_translatable_enabled"":true," + + @"""view_counts_everywhere_api_enabled"":true," + + @"""longform_notetweets_consumption_enabled"":true," + + @"""responsive_web_twitter_article_tweet_consumption_enabled"":false," + + @"""tweet_awards_web_tipping_enabled"":false," + + @"""freedom_of_speech_not_reach_fetch_enabled"":true," + + @"""standardized_nudges_misinfo"":true," + + @"""tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled"":true," + + @"""longform_notetweets_rich_text_read_enabled"":true," + + @"""longform_notetweets_inline_media_enabled"":true," + + @"""responsive_web_media_download_video_enabled"":false," + + @"""responsive_web_enhance_cards_enabled"":false" + + "}", + }; + } + + public async Task Send(IApiConnection apiConnection) + { + var param = this.CreateParameters(); + using var stream = await apiConnection.GetStreamAsync(EndpointUri, param); + using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(stream, XmlDictionaryReaderQuotas.Max); + var xElm = XElement.Load(jsonReader); + + return TimelineTweet.ExtractTimelineTweets(xElm); + } + } +} diff --git a/OpenTween/Api/GraphQL/TimelineTweet.cs b/OpenTween/Api/GraphQL/TimelineTweet.cs new file mode 100644 index 000000000..62949ef87 --- /dev/null +++ b/OpenTween/Api/GraphQL/TimelineTweet.cs @@ -0,0 +1,143 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using System.Xml.XPath; +using OpenTween.Api.DataModel; + +namespace OpenTween.Api.GraphQL +{ + public class TimelineTweet + { + public const string TypeName = nameof(TimelineTweet); + + public XElement Element { get; } + + public TimelineTweet(XElement element) + { + var typeName = element.Element("itemType")?.Value; + if (typeName != TypeName) + throw new ArgumentException($"Invalid itemType: {typeName}", nameof(element)); + + this.Element = element; + } + + public TwitterStatus ToTwitterStatus() + { + var tweetElm = this.Element.Element("tweet_results")?.Element("result") ?? throw CreateParseError(); + return this.ParseTweet(tweetElm); + } + + private TwitterStatus ParseTweet(XElement tweetElm) + { + var tweetLegacyElm = tweetElm.Element("legacy") ?? throw CreateParseError(); + var userElm = tweetElm.Element("core")?.Element("user_results")?.Element("result") ?? throw CreateParseError(); + var userLegacyElm = userElm.Element("legacy") ?? throw CreateParseError(); + var retweetedTweetElm = tweetLegacyElm.Element("retweeted_status_result")?.Element("result"); + + static string GetText(XElement elm, string name) + => elm.Element(name)?.Value ?? throw CreateParseError(); + + static string? GetTextOrNull(XElement elm, string name) + => elm.Element(name)?.Value; + + return new() + { + IdStr = GetText(tweetElm, "rest_id"), + Source = GetText(tweetElm, "source"), + CreatedAt = GetText(tweetLegacyElm, "created_at"), + FullText = GetText(tweetLegacyElm, "full_text"), + InReplyToScreenName = GetTextOrNull(tweetLegacyElm, "in_reply_to_screen_name"), + InReplyToStatusIdStr = GetTextOrNull(tweetLegacyElm, "in_reply_to_status_id_str"), + InReplyToUserId = GetTextOrNull(tweetLegacyElm, "in_reply_to_user_id_str") is string userId ? long.Parse(userId) : null, + Favorited = GetTextOrNull(tweetLegacyElm, "favorited") is string favorited ? favorited == "true" : null, + Entities = new() + { + UserMentions = tweetLegacyElm.XPathSelectElements("entities/user_mentions/item") + .Select(x => new TwitterEntityMention() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + ScreenName = GetText(x, "screen_name"), + }) + .ToArray(), + Urls = tweetLegacyElm.XPathSelectElements("entities/urls/item") + .Select(x => new TwitterEntityUrl() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + DisplayUrl = GetText(x, "display_url"), + ExpandedUrl = GetText(x, "expanded_url"), + Url = GetText(x, "url"), + }) + .ToArray(), + Hashtags = tweetLegacyElm.XPathSelectElements("entities/hashtags/item") + .Select(x => new TwitterEntityHashtag() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + Text = GetText(x, "text"), + }) + .ToArray(), + }, + ExtendedEntities = new() + { + Media = tweetLegacyElm.XPathSelectElements("extended_entities/media/item") + .Select(x => new TwitterEntityMedia() + { + Indices = x.XPathSelectElements("indices/item").Select(x => int.Parse(x.Value)).ToArray(), + DisplayUrl = GetText(x, "display_url"), + ExpandedUrl = GetText(x, "expanded_url"), + Url = GetText(x, "url"), + MediaUrlHttps = GetText(x, "media_url_https"), + Type = GetText(x, "type"), + AltText = GetTextOrNull(x, "ext_alt_text"), + }) + .ToArray(), + }, + User = new() + { + Id = long.Parse(GetText(userElm, "rest_id")), + IdStr = GetText(userElm, "rest_id"), + Name = GetText(userLegacyElm, "name"), + ProfileImageUrlHttps = GetText(userLegacyElm, "profile_image_url_https"), + ScreenName = GetText(userLegacyElm, "screen_name"), + Protected = GetTextOrNull(userLegacyElm, "protected") == "true", + }, + RetweetedStatus = retweetedTweetElm != null ? this.ParseTweet(retweetedTweetElm) : null, + }; + } + + private static Exception CreateParseError() + => throw new WebApiException("Parse error on TimelineTweet"); + + public static TimelineTweet[] ExtractTimelineTweets(XElement element) + { + return element.XPathSelectElements($"//itemContent[itemType[text()='{TypeName}']][tweetDisplayType[text()='Tweet']]") + .Select(x => new TimelineTweet(x)) + .ToArray(); + } + } +} diff --git a/OpenTween/Api/MicrosoftTranslatorApi.cs b/OpenTween/Api/MicrosoftTranslatorApi.cs index 6400a7ad7..ab5e58b7a 100644 --- a/OpenTween/Api/MicrosoftTranslatorApi.cs +++ b/OpenTween/Api/MicrosoftTranslatorApi.cs @@ -85,7 +85,7 @@ await this.UpdateAccessTokenIfExpired() request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.AccessToken); var escapedText = JsonUtils.EscapeJsonString(text); - var json = $@"[{{""Text"": ""{escapedText}""}}]"; + var json = $$"""[{"Text": "{{escapedText}}"}]"""; using var body = new StringContent(json, Encoding.UTF8, "application/json"); request.Content = body; diff --git a/OpenTween/Api/TwitterApi.cs b/OpenTween/Api/TwitterApi.cs index 3cca489fe..30b8cfb7c 100644 --- a/OpenTween/Api/TwitterApi.cs +++ b/OpenTween/Api/TwitterApi.cs @@ -30,6 +30,7 @@ using System.Threading.Tasks; using OpenTween.Api.DataModel; using OpenTween.Connection; +using OpenTween.Models; namespace OpenTween.Api { @@ -136,12 +137,12 @@ public Task StatusesUserTimeline(string screenName, int? count return this.Connection.GetAsync(endpoint, param, "/statuses/user_timeline"); } - public Task StatusesShow(long statusId) + public Task StatusesShow(TwitterStatusId statusId) { var endpoint = new Uri("statuses/show.json", UriKind.Relative); var param = new Dictionary { - ["id"] = statusId.ToString(), + ["id"] = statusId.Id, ["include_entities"] = "true", ["include_ext_alt_text"] = "true", ["tweet_mode"] = "extended", @@ -166,7 +167,7 @@ public Task StatusesLookup(IReadOnlyList statusIds) public Task> StatusesUpdate( string status, - long? replyToId, + TwitterStatusId? replyToId, IReadOnlyList? mediaIds, bool? autoPopulateReplyMetadata = null, IReadOnlyList? excludeReplyUserIds = null, @@ -182,7 +183,7 @@ public Task> StatusesUpdate( }; if (replyToId != null) - param["in_reply_to_status_id"] = replyToId.ToString(); + param["in_reply_to_status_id"] = replyToId.Id; if (mediaIds != null && mediaIds.Count > 0) param.Add("media_ids", string.Join(",", mediaIds)); if (autoPopulateReplyMetadata != null) @@ -195,23 +196,23 @@ public Task> StatusesUpdate( return this.Connection.PostLazyAsync(endpoint, param); } - public Task> StatusesDestroy(long statusId) + public Task> StatusesDestroy(TwitterStatusId statusId) { var endpoint = new Uri("statuses/destroy.json", UriKind.Relative); var param = new Dictionary { - ["id"] = statusId.ToString(), + ["id"] = statusId.Id, }; return this.Connection.PostLazyAsync(endpoint, param); } - public Task> StatusesRetweet(long statusId) + public Task> StatusesRetweet(TwitterStatusId statusId) { var endpoint = new Uri("statuses/retweet.json", UriKind.Relative); var param = new Dictionary { - ["id"] = statusId.ToString(), + ["id"] = statusId.Id, ["include_entities"] = "true", ["include_ext_alt_text"] = "true", ["tweet_mode"] = "extended", @@ -444,38 +445,41 @@ public Task> DirectMessagesEventsNew(long re var attachment = ""; if (mediaId != null) { - attachment = "," + $@" - ""attachment"": {{ - ""type"": ""media"", - ""media"": {{ - ""id"": ""{JsonUtils.EscapeJsonString(mediaId.ToString())}"" - }} - }}"; + attachment = ",\r\n" + $$""" + "attachment": { + "type": "media", + "media": { + "id": "{{JsonUtils.EscapeJsonString(mediaId.ToString())}}" + } + } + """; } - var json = $@"{{ - ""event"": {{ - ""type"": ""message_create"", - ""message_create"": {{ - ""target"": {{ - ""recipient_id"": ""{JsonUtils.EscapeJsonString(recipientId.ToString())}"" - }}, - ""message_data"": {{ - ""text"": ""{JsonUtils.EscapeJsonString(text)}""{attachment} - }} - }} - }} -}}"; + var json = $$""" + { + "event": { + "type": "message_create", + "message_create": { + "target": { + "recipient_id": "{{JsonUtils.EscapeJsonString(recipientId.ToString())}}" + }, + "message_data": { + "text": "{{JsonUtils.EscapeJsonString(text)}}"{{attachment}} + } + } + } + } + """; return this.Connection.PostJsonAsync(endpoint, json); } - public Task DirectMessagesEventsDestroy(string eventId) + public Task DirectMessagesEventsDestroy(TwitterDirectMessageId eventId) { var endpoint = new Uri("direct_messages/events/destroy.json", UriKind.Relative); var param = new Dictionary { - ["id"] = eventId.ToString(), + ["id"] = eventId.Id, }; // なぜか application/x-www-form-urlencoded でパラメーターを送ると Bad Request になる謎仕様 @@ -544,24 +548,24 @@ public Task FavoritesList(int? count = null, long? maxId = null return this.Connection.GetAsync(endpoint, param, "/favorites/list"); } - public Task> FavoritesCreate(long statusId) + public Task> FavoritesCreate(TwitterStatusId statusId) { var endpoint = new Uri("favorites/create.json", UriKind.Relative); var param = new Dictionary { - ["id"] = statusId.ToString(), + ["id"] = statusId.Id, ["tweet_mode"] = "extended", }; return this.Connection.PostLazyAsync(endpoint, param); } - public Task> FavoritesDestroy(long statusId) + public Task> FavoritesDestroy(TwitterStatusId statusId) { var endpoint = new Uri("favorites/destroy.json", UriKind.Relative); var param = new Dictionary { - ["id"] = statusId.ToString(), + ["id"] = statusId.Id, ["tweet_mode"] = "extended", }; @@ -806,7 +810,7 @@ public Task MediaMetadataCreate(long mediaId, string altText) var endpoint = new Uri("https://upload.twitter.com/1.1/media/metadata/create.json"); var escapedAltText = JsonUtils.EscapeJsonString(altText); - var json = $@"{{""media_id"": ""{mediaId}"", ""alt_text"": {{""text"": ""{escapedAltText}""}}}}"; + var json = $$$"""{"media_id": "{{{mediaId}}}", "alt_text": {"text": "{{{escapedAltText}}}"}}"""; return this.Connection.PostJsonAsync(endpoint, json); } diff --git a/OpenTween/DateTimeUtc.cs b/OpenTween/DateTimeUtc.cs index b89abd391..cc05e2b44 100644 --- a/OpenTween/DateTimeUtc.cs +++ b/OpenTween/DateTimeUtc.cs @@ -152,6 +152,9 @@ public string ToLocalTimeString(string format) public static DateTimeUtc FromUnixTime(long unixTime) => UnixEpoch + TimeSpan.FromTicks(unixTime * TimeSpan.TicksPerSecond); + public static DateTimeUtc FromUnixTimeMilliseconds(long unixTimeMs) + => UnixEpoch + TimeSpan.FromTicks(unixTimeMs * TimeSpan.TicksPerMillisecond); + public static DateTimeUtc Parse(string input, IFormatProvider formatProvider) => new(DateTimeOffset.Parse(input, formatProvider, DateTimeStyles.AssumeUniversal)); diff --git a/OpenTween/Models/InternalStorageTabModel.cs b/OpenTween/Models/InternalStorageTabModel.cs index 81ee24140..3e23e9277 100644 --- a/OpenTween/Models/InternalStorageTabModel.cs +++ b/OpenTween/Models/InternalStorageTabModel.cs @@ -38,9 +38,9 @@ namespace OpenTween.Models { public abstract class InternalStorageTabModel : TabModel { - protected readonly ConcurrentDictionary internalPosts = new(); + protected readonly ConcurrentDictionary internalPosts = new(); - public override ConcurrentDictionary Posts + public override ConcurrentDictionary Posts => this.internalPosts; protected InternalStorageTabModel(string tabName) @@ -58,7 +58,7 @@ public override void AddPostQueue(PostClass post) base.AddPostQueue(post); } - public override void EnqueueRemovePost(long statusId, bool setIsDeleted) + public override void EnqueueRemovePost(PostId statusId, bool setIsDeleted) { base.EnqueueRemovePost(statusId, setIsDeleted); @@ -69,7 +69,7 @@ public override void EnqueueRemovePost(long statusId, bool setIsDeleted) } } - public override bool RemovePostImmediately(long statusId) + public override bool RemovePostImmediately(PostId statusId) { if (!base.RemovePostImmediately(statusId)) return false; @@ -85,7 +85,7 @@ public override void ClearIDs() this.internalPosts.Clear(); } - internal override bool SetReadState(long statusId, bool read) + internal override bool SetReadState(PostId statusId, bool read) { if (this.Posts.TryGetValue(statusId, out var post)) post.IsRead = read; diff --git a/OpenTween/Models/PostClass.cs b/OpenTween/Models/PostClass.cs index e52e333f7..7e2496769 100644 --- a/OpenTween/Models/PostClass.cs +++ b/OpenTween/Models/PostClass.cs @@ -38,29 +38,34 @@ namespace OpenTween.Models { - public class PostClass : ICloneable + public record PostClass() { public readonly record struct StatusGeo( double Longitude, double Latitude ); - public string Nickname { get; set; } = ""; + public string Nickname { get; init; } = ""; - public string TextFromApi { get; set; } = ""; + public string TextFromApi { get; init; } = ""; /// スクリーンリーダーでの読み上げを考慮したテキスト - public string AccessibleText { get; set; } = ""; + public string AccessibleText { get; init; } = ""; - public string ImageUrl { get; set; } = ""; + public string ImageUrl { get; init; } = ""; - public string ScreenName { get; set; } = ""; + public string ScreenName { get; init; } = ""; - public DateTimeUtc CreatedAt { get; set; } + public DateTimeUtc CreatedAt { get; init; } - public long StatusId { get; set; } + /// ソート用の日時 + /// + /// はリツイートの場合にRT元の日時を表すため、 + /// ソート用に使用するタイムスタンプを保持する必要がある + /// + public DateTimeUtc CreatedAtForSorting { get; init; } - private bool isFav; + public PostId StatusId { get; init; } = null!; public string Text { @@ -75,59 +80,40 @@ public string Text return expandedHtml; } - set => this.text = value; + init => this.text = value; } private string text = ""; - public bool IsRead { get; set; } - - public bool IsReply { get; set; } - - public bool IsExcludeReply { get; set; } - - private bool isProtect; + public bool IsReply { get; init; } - public bool IsOwl { get; set; } - - private bool isMark; + public string? InReplyToUser { get; init; } - public string? InReplyToUser { get; set; } + public string Source { get; init; } = ""; - private long? inReplyToStatusId; + public Uri? SourceUri { get; init; } - public string Source { get; set; } = ""; + public List<(long UserId, string ScreenName)> ReplyToList { get; init; } = new(); - public Uri? SourceUri { get; set; } + public bool IsMe { get; init; } - public List<(long UserId, string ScreenName)> ReplyToList { get; set; } + public bool IsDm { get; init; } - public bool IsMe { get; set; } + public long UserId { get; init; } - public bool IsDm { get; set; } + public string? RetweetedBy { get; init; } - public long UserId { get; set; } + public PostId? RetweetedId { get; init; } - public bool FilterHit { get; set; } + public long? RetweetedByUserId { get; init; } - public string? RetweetedBy { get; set; } + public long? InReplyToUserId { get; init; } - public long? RetweetedId { get; set; } + public List Media { get; init; } = new(); - private bool isDeleted = false; - private StatusGeo? postGeo = null; - - public int RetweetedCount { get; set; } + public PostId[] QuoteStatusIds { get; init; } = Array.Empty(); - public long? RetweetedByUserId { get; set; } - - public long? InReplyToUserId { get; set; } - - public List Media { get; set; } - - public long[] QuoteStatusIds { get; set; } - - public ExpandedUrlInfo[] ExpandedUrls { get; set; } + public ExpandedUrlInfo[] ExpandedUrls { get; init; } = Array.Empty(); /// /// に含まれる t.co の展開後の URL を保持するクラス @@ -185,11 +171,6 @@ object ICloneable.Clone() => this.Clone(); } - public int FavoritedCount { get; set; } - - private States states = States.None; - private bool expandComplatedAll = false; - [Flags] private enum States { @@ -200,129 +181,53 @@ private enum States Geo = 8, } - public PostClass() + public int StateIndex { - this.Media = new List(); - this.ReplyToList = new List<(long, string)>(); - this.QuoteStatusIds = Array.Empty(); - this.ExpandedUrls = Array.Empty(); + get + { + var states = States.None; + + if (this.IsProtect) + states |= States.Protect; + if (this.IsMark) + states |= States.Mark; + if (this.InReplyToStatusId != null) + states |= States.Reply; + if (this.PostGeo != null) + states |= States.Geo; + + return (int)states - 1; + } } public string TextSingleLine => this.TextFromApi.Replace("\n", " "); - public bool IsFav - { - get - { - if (this.RetweetedId != null) - { - var post = this.RetweetSource; - if (post != null) - { - return post.IsFav; - } - } - - return this.isFav; - } + public PostId? InReplyToStatusId { get; init; } - set - { - this.isFav = value; - if (this.RetweetedId != null) - { - var post = this.RetweetSource; - if (post != null) - { - post.IsFav = value; - } - } - } - } + public bool IsProtect { get; init; } - public bool IsProtect - { - get => this.isProtect; - set - { - if (value) - this.states |= States.Protect; - else - this.states &= ~States.Protect; + public StatusGeo? PostGeo { get; set; } - this.isProtect = value; - } - } + public bool IsFav { get; set; } - public bool IsMark - { - get => this.isMark; - set - { - if (value) - this.states |= States.Mark; - else - this.states &= ~States.Mark; + public bool IsRead { get; set; } - this.isMark = value; - } - } + public bool IsExcludeReply { get; set; } - public long? InReplyToStatusId - { - get => this.inReplyToStatusId; - set - { - if (value != null) - this.states |= States.Reply; - else - this.states &= ~States.Reply; + public bool IsOwl { get; set; } - this.inReplyToStatusId = value; - } - } + public bool IsMark { get; set; } - public bool IsDeleted - { - get => this.isDeleted; - set - { - if (value) - { - this.InReplyToStatusId = null; - this.InReplyToUser = ""; - this.InReplyToUserId = null; - this.IsReply = false; - this.ReplyToList = new List<(long, string)>(); - this.states = States.None; - } - this.isDeleted = value; - } - } + public bool IsDeleted { get; set; } - protected virtual PostClass? RetweetSource - => this.RetweetedId != null ? TabInformations.GetInstance().RetweetSource(this.RetweetedId.Value) : null; + public bool FilterHit { get; set; } - public StatusGeo? PostGeo - { - get => this.postGeo; - set - { - if (value != null) - { - this.states |= States.Geo; - } - else - { - this.states &= ~States.Geo; - } - this.postGeo = value; - } - } + public int RetweetedCount { get; set; } - public int StateIndex - => (int)this.states - 1; + public int FavoritedCount { get; set; } + + private bool expandComplatedAll = false; // 互換性のために用意 public string SourceHtml @@ -333,7 +238,7 @@ public string SourceHtml return WebUtility.HtmlEncode(this.Source); return string.Format( - "{1}", + """{1}""", WebUtility.HtmlEncode(this.SourceUri.AbsoluteUri), WebUtility.HtmlEncode(this.Source)); } @@ -384,13 +289,15 @@ public PostClass ConvertToOriginalPost() if (this.RetweetedId == null) throw new InvalidOperationException(); - var originalPost = this.Clone(); - - originalPost.StatusId = this.RetweetedId.Value; - originalPost.RetweetedId = null; - originalPost.RetweetedBy = ""; - originalPost.RetweetedByUserId = null; - originalPost.RetweetedCount = 1; + var originalPost = this with + { + StatusId = this.RetweetedId, + CreatedAtForSorting = this.CreatedAt, + RetweetedId = null, + RetweetedBy = "", + RetweetedByUserId = null, + RetweetedCount = 1, + }; return originalPost; } @@ -435,60 +342,5 @@ private string ReplaceToExpandedUrl(string html, out bool completedAll) return html; } - - public PostClass Clone() - { - var clone = (PostClass)this.MemberwiseClone(); - clone.ReplyToList = new List<(long, string)>(this.ReplyToList); - clone.Media = new List(this.Media); - clone.QuoteStatusIds = this.QuoteStatusIds.ToArray(); - clone.ExpandedUrls = this.ExpandedUrls.Select(x => x.Clone()).ToArray(); - - return clone; - } - - object ICloneable.Clone() - => this.Clone(); - - public override bool Equals(object? obj) - { - if (obj == null || this.GetType() != obj.GetType()) return false; - return this.Equals((PostClass)obj); - } - - public bool Equals(PostClass? other) - { - if (other == null) return false; - return (this.Nickname == other.Nickname) && - (this.TextFromApi == other.TextFromApi) && - (this.ImageUrl == other.ImageUrl) && - (this.ScreenName == other.ScreenName) && - (this.CreatedAt == other.CreatedAt) && - (this.StatusId == other.StatusId) && - (this.IsFav == other.IsFav) && - (this.Text == other.Text) && - (this.IsRead == other.IsRead) && - (this.IsReply == other.IsReply) && - (this.IsExcludeReply == other.IsExcludeReply) && - (this.IsProtect == other.IsProtect) && - (this.IsOwl == other.IsOwl) && - (this.IsMark == other.IsMark) && - (this.InReplyToUser == other.InReplyToUser) && - (this.InReplyToStatusId == other.InReplyToStatusId) && - (this.Source == other.Source) && - (this.SourceUri == other.SourceUri) && - this.ReplyToList.SequenceEqual(other.ReplyToList) && - (this.IsMe == other.IsMe) && - (this.IsDm == other.IsDm) && - (this.UserId == other.UserId) && - (this.FilterHit == other.FilterHit) && - (this.RetweetedBy == other.RetweetedBy) && - (this.RetweetedId == other.RetweetedId) && - (this.IsDeleted == other.IsDeleted) && - (this.InReplyToUserId == other.InReplyToUserId); - } - - public override int GetHashCode() - => this.StatusId.GetHashCode(); } } diff --git a/OpenTween/Models/PostId.cs b/OpenTween/Models/PostId.cs new file mode 100644 index 000000000..82b36654a --- /dev/null +++ b/OpenTween/Models/PostId.cs @@ -0,0 +1,68 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OpenTween.Models +{ + [DebuggerDisplay("{IdType}:{Id}")] + public abstract class PostId + : IEquatable, IComparable + { + public abstract string IdType { get; } + + public abstract string Id { get; } + + public virtual int CompareTo(PostId other) + { + var compareByIdType = this.IdType.CompareTo(other.IdType); + if (compareByIdType != 0) + return compareByIdType; + + return this.Id.CompareTo(other.Id); + } + + public virtual bool Equals(PostId other) + => this.IdType == other.IdType && this.Id == other.Id; + + public override bool Equals(object obj) + => obj is PostId otherId && this.Equals(otherId); + + public override int GetHashCode() + => this.IdType.GetHashCode() ^ this.Id.GetHashCode(); + + public override string ToString() + => this.Id; + + public static bool operator ==(PostId? left, PostId? right) + => EqualityComparer.Default.Equals(left, right); + + public static bool operator !=(PostId? left, PostId? right) + => !EqualityComparer.Default.Equals(left, right); + } +} diff --git a/OpenTween/Models/TabInformations.cs b/OpenTween/Models/TabInformations.cs index 9ef2b85b4..eee786929 100644 --- a/OpenTween/Models/TabInformations.cs +++ b/OpenTween/Models/TabInformations.cs @@ -47,10 +47,10 @@ public IReadOnlyTabCollection Tabs public MuteTabModel MuteTab { get; private set; } = new(); - public ConcurrentDictionary Posts { get; } = new(); + public ConcurrentDictionary Posts { get; } = new(); - private readonly Dictionary quotes = new(); - private readonly ConcurrentDictionary retweetsCount = new(); + private readonly Dictionary quotes = new(); + private readonly ConcurrentDictionary retweetsCount = new(); public Stack RemovedTab { get; } = new(); @@ -62,7 +62,7 @@ public IReadOnlyTabCollection Tabs // AddPost(複数回) -> DistributePosts -> SubmitUpdate private readonly TabCollection tabs = new(); - private readonly ConcurrentQueue addQueue = new(); + private readonly ConcurrentQueue addQueue = new(); /// 通知サウンドを再生する優先順位 private readonly Dictionary notifyPriorityByTabType = new() @@ -156,7 +156,6 @@ public void RemoveTab(string tabName) { var exist = false; var id = tb.GetStatusIdAt(idx); - if (id < 0) continue; foreach (var tab in this.Tabs) { if (tab != tb && tab != dmTab) @@ -176,8 +175,31 @@ public void RemoveTab(string tabName) } } + public bool CanUndoRemovedTab + => this.RemovedTab.Count > 0; + + public TabModel UndoRemovedTab() + { + if (!this.CanUndoRemovedTab) + throw new TabException("There isn't removed tab."); + + var tab = this.RemovedTab.Pop(); + if (this.ContainsTab(tab.TabName)) + { + this.RemovedTab.Push(tab); + var message = string.Format(Properties.Resources.UndoRemovedTab_DuplicateError, tab.TabName); + throw new TabException(message); + } + + this.AddTab(tab); + + return tab; + } + public void MoveTab(int newIndex, TabModel tab) { + if (newIndex < 0 || newIndex >= this.tabs.Count) + throw new ArgumentOutOfRangeException(nameof(newIndex)); if (!this.ContainsTab(tab)) throw new ArgumentOutOfRangeException(nameof(tab)); @@ -360,10 +382,7 @@ public SortOrder ToggleSortOrder(ComparerMode sortMode) return this.SortOrder; } - public PostClass? RetweetSource(long id) - => this.Posts.TryGetValue(id, out var status) ? status : null; - - public void RemovePostFromAllTabs(long statusId, bool setIsDeleted) + public void RemovePostFromAllTabs(PostId statusId, bool setIsDeleted) { foreach (var tab in this.Tabs) { @@ -395,7 +414,7 @@ public int SubmitUpdate( isDeletePost = false; var addedCountTotal = 0; - var removedIdsAll = new List(); + var removedIdsAll = new List(); var notifyPostsList = new List(); var currentNotifyPriority = -1; @@ -627,7 +646,7 @@ private int UpdateRetweetCount(PostClass retweetPost) if (retweetPost.RetweetedId == null) throw new InvalidOperationException(); - var retweetedId = retweetPost.RetweetedId.Value; + var retweetedId = retweetPost.RetweetedId; return this.retweetsCount.AddOrUpdate(retweetedId, 1, (k, v) => v >= 10 ? 1 : v + 1); } @@ -650,7 +669,7 @@ public bool AddQuoteTweet(PostClass item) /// 変更するツイートのID /// 既読状態 /// 既読状態に変化があれば true、変化がなければ false - public bool SetReadAllTab(long statusId, bool read) + public bool SetReadAllTab(PostId statusId, bool read) { lock (this.lockObj) { @@ -693,7 +712,7 @@ public void SetReadHomeTab() } } - public PostClass? this[long id] + public PostClass? this[PostId id] { get { @@ -709,7 +728,7 @@ public PostClass? this[long id] } } - public bool ContainsKey(long id) + public bool ContainsKey(PostId id) { // DM,公式検索は非対応 lock (this.lockObj) @@ -739,7 +758,7 @@ public void FilterAll() lock (this.lockObj) { var homeTab = this.HomeTab; - var detachedIdsAll = Enumerable.Empty(); + var detachedIdsAll = Enumerable.Empty(); foreach (var tab in this.Tabs.OfType().ToArray()) { @@ -841,6 +860,12 @@ public void ClearTabIds(string tabName) var hit = false; foreach (var tab in this.Tabs) { + if (tab is InternalStorageTabModel) + continue; + + if (tab == tb) + continue; + if (tab.Contains(id)) { hit = true; diff --git a/OpenTween/Models/TabModel.cs b/OpenTween/Models/TabModel.cs index d9c054279..11498f1d5 100644 --- a/OpenTween/Models/TabModel.cs +++ b/OpenTween/Models/TabModel.cs @@ -61,12 +61,12 @@ public abstract class TabModel public abstract MyCommon.TabUsageType TabType { get; } - public virtual ConcurrentDictionary Posts + public virtual ConcurrentDictionary Posts => TabInformations.GetInstance().Posts; public int AllCount => this.ids.Count; - public long[] StatusIds => this.ids.ToArray(); + public PostId[] StatusIds => this.ids.ToArray(); public bool IsDefaultTabType => this.TabType.IsDefault(); @@ -79,11 +79,11 @@ public virtual ConcurrentDictionary Posts /// public virtual bool IsPermanentTabType => true; - public long[] SelectedStatusIds + public PostId[] SelectedStatusIds => this.selectedStatusIds.ToArray(); - public long SelectedStatusId - => this.selectedStatusIds.DefaultIfEmpty(-1).First(); + public PostId? SelectedStatusId + => this.selectedStatusIds.Count > 0 ? this.selectedStatusIds[0] : null; public PostClass[] SelectedPosts => this.selectedStatusIds.Select(x => this.Posts[x]).ToArray(); @@ -96,11 +96,11 @@ public int SelectedIndex get { var statusId = this.SelectedStatusId; - return statusId != -1 ? this.IndexOf(statusId) : -1; + return statusId != null ? this.IndexOf(statusId) : -1; } } - public long? AnchorStatusId { get; set; } + public PostId? AnchorStatusId { get; set; } public PostClass? AnchorPost { @@ -109,7 +109,7 @@ public PostClass? AnchorPost if (this.AnchorStatusId == null) return null; - if (!this.Posts.TryGetValue(this.AnchorStatusId.Value, out var post)) + if (!this.Posts.TryGetValue(this.AnchorStatusId, out var post)) return null; return post; @@ -117,11 +117,11 @@ public PostClass? AnchorPost set => this.AnchorStatusId = value?.StatusId; } - private IndexedSortedSet ids = new(); + private IndexedSortedSet ids = new(); private ConcurrentQueue addQueue = new(); - private readonly ConcurrentQueue removeQueue = new(); - private SortedSet unreadIds = new(); - private List selectedStatusIds = new(); + private readonly ConcurrentQueue removeQueue = new(); + private SortedSet unreadIds = new(); + private List selectedStatusIds = new(); private readonly object lockObj = new(); @@ -131,7 +131,7 @@ protected TabModel(string tabName) public abstract Task RefreshAsync(Twitter tw, bool backward, bool startup, IProgress progress); private readonly record struct TemporaryId( - long StatusId, + PostId StatusId, bool Read ); @@ -144,7 +144,7 @@ public virtual void AddPostQueue(PostClass post) } // 無条件に追加 - internal bool AddPostImmediately(long statusId, bool read) + internal bool AddPostImmediately(PostId statusId, bool read) { if (!this.ids.Add(statusId)) return false; @@ -155,9 +155,9 @@ internal bool AddPostImmediately(long statusId, bool read) return true; } - public IReadOnlyList AddSubmit() + public IReadOnlyList AddSubmit() { - var addedIds = new List(); + var addedIds = new List(); while (this.addQueue.TryDequeue(out var tId)) { @@ -168,10 +168,10 @@ public IReadOnlyList AddSubmit() return addedIds; } - public virtual void EnqueueRemovePost(long statusId, bool setIsDeleted) + public virtual void EnqueueRemovePost(PostId statusId, bool setIsDeleted) => this.removeQueue.Enqueue(statusId); - public virtual bool RemovePostImmediately(long statusId) + public virtual bool RemovePostImmediately(PostId statusId) { if (!this.ids.Remove(statusId)) return false; @@ -181,9 +181,9 @@ public virtual bool RemovePostImmediately(long statusId) return true; } - public IReadOnlyList RemoveSubmit() + public IReadOnlyList RemoveSubmit() { - var removedIds = new List(); + var removedIds = new List(); while (this.removeQueue.TryDequeue(out var statusId)) { @@ -194,6 +194,18 @@ public IReadOnlyList RemoveSubmit() return removedIds; } + /// + /// タブ内にある が一致する発言を置き換える + /// + /// 置き換えに成功した場合は true、タブ内に存在しない発言などで失敗した場合は false + public bool ReplacePost(PostClass post) + { + if (!this.Posts.TryGetValue(post.StatusId, out var origPost)) + return false; + + return this.Posts.TryUpdate(post.StatusId, post, origPost); + } + public void SelectPosts(int[] indices) { bool IsValidIndex(int index) @@ -243,54 +255,52 @@ private void ApplySortMode() { var sign = this.SortOrder == SortOrder.Ascending ? 1 : -1; - Comparison comparison; - if (this.SortMode == ComparerMode.Id) + Comparison postComparison = this.SortMode switch { - comparison = (x, y) => sign * x.CompareTo(y); - } - else + ComparerMode.Id => + (x, y) => Comparer.Default.Compare(x?.CreatedAtForSorting, y?.CreatedAtForSorting), + ComparerMode.Name => + (x, y) => Comparer.Default.Compare(x?.ScreenName, y?.ScreenName), + ComparerMode.Nickname => + (x, y) => Comparer.Default.Compare(x?.Nickname, y?.Nickname), + ComparerMode.Source => + (x, y) => Comparer.Default.Compare(x?.Source, y?.Source), + _ => + (x, y) => Comparer.Default.Compare(x?.TextFromApi, y?.TextFromApi), + }; + + Comparison comparison = (x, y) => { - Comparison postComparison = this.SortMode switch - { - ComparerMode.Name => (x, y) => Comparer.Default.Compare(x?.ScreenName, y?.ScreenName), - ComparerMode.Nickname => (x, y) => Comparer.Default.Compare(x?.Nickname, y?.Nickname), - ComparerMode.Source => (x, y) => Comparer.Default.Compare(x?.Source, y?.Source), - _ => (x, y) => Comparer.Default.Compare(x?.TextFromApi, y?.TextFromApi), - }; - - comparison = (x, y) => - { - this.Posts.TryGetValue(x, out var xPost); - this.Posts.TryGetValue(y, out var yPost); + this.Posts.TryGetValue(x, out var xPost); + this.Posts.TryGetValue(y, out var yPost); - var compare = sign * postComparison(xPost, yPost); - if (compare != 0) - return compare; + var compare = sign * postComparison(xPost, yPost); + if (compare != 0) + return compare; - // 同値であれば status_id で比較する - return sign * x.CompareTo(y); - }; - } + // 同値であれば status_id で比較する + return sign * x.CompareTo(y); + }; - var comparer = Comparer.Create(comparison); + var comparer = Comparer.Create(comparison); - this.ids = new IndexedSortedSet(this.ids, comparer); - this.unreadIds = new SortedSet(this.unreadIds, comparer); + this.ids = new IndexedSortedSet(this.ids, comparer); + this.unreadIds = new SortedSet(this.unreadIds, comparer); } /// /// 次に表示する未読ツイートのIDを返します。 - /// ただし、未読がない場合または UnreadManage が false の場合は -1 を返します + /// ただし、未読がない場合または UnreadManage が false の場合は null を返します /// - public long NextUnreadId + public PostId? NextUnreadId { get { if (!this.UnreadManage || !SettingManager.Instance.Common.UnreadManage) - return -1L; + return null; if (this.unreadIds.Count == 0) - return -1L; + return null; // unreadIds はリストのインデックス番号順に並んでいるため、 // 例えば ID 順の整列であれば昇順なら上から、降順なら下から順に返せば過去→現在の順になる @@ -307,7 +317,7 @@ public int NextUnreadIndex get { var unreadId = this.NextUnreadId; - return unreadId != -1 ? this.IndexOf(unreadId) : -1; + return unreadId != null ? this.IndexOf(unreadId) : -1; } } @@ -329,7 +339,7 @@ public int UnreadCount /// /// 未読ツイートの ID を配列で返します /// - public long[] GetUnreadIds() + public PostId[] GetUnreadIds() { lock (this.lockObj) return this.unreadIds.ToArray(); @@ -344,7 +354,7 @@ public long[] GetUnreadIds() /// 変更するツイートのID /// 既読状態 /// 既読状態に変化があれば true、変化がなければ false - internal virtual bool SetReadState(long statusId, bool read) + internal virtual bool SetReadState(PostId statusId, bool read) { if (!this.ids.Contains(statusId)) throw new ArgumentOutOfRangeException(nameof(statusId)); @@ -355,7 +365,7 @@ internal virtual bool SetReadState(long statusId, bool read) return this.unreadIds.Add(statusId); } - public bool Contains(long statusId) + public bool Contains(PostId statusId) => this.ids.Contains(statusId); public PostClass this[int index] @@ -394,13 +404,13 @@ public PostClass this[int index] } } - public long[] GetStatusIdAt(IEnumerable indexes) + public PostId[] GetStatusIdAt(IEnumerable indexes) => indexes.Select(x => this.GetStatusIdAt(x)).ToArray(); - public long GetStatusIdAt(int index) + public PostId GetStatusIdAt(int index) => this.ids[index]; - public int[] IndexOf(long[] statusIds) + public int[] IndexOf(PostId[] statusIds) { if (statusIds == null) throw new ArgumentNullException(nameof(statusIds)); @@ -408,7 +418,7 @@ public int[] IndexOf(long[] statusIds) return statusIds.Select(x => this.IndexOf(x)).ToArray(); } - public int IndexOf(long statusId) + public int IndexOf(PostId statusId) => this.ids.IndexOf(statusId); public IEnumerable SearchPostsAll(Func stringComparer) diff --git a/OpenTween/Models/TwitterDirectMessageId.cs b/OpenTween/Models/TwitterDirectMessageId.cs new file mode 100644 index 000000000..2237ee4a8 --- /dev/null +++ b/OpenTween/Models/TwitterDirectMessageId.cs @@ -0,0 +1,41 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OpenTween.Models +{ + public class TwitterDirectMessageId : PostId + { + public override string IdType => "twitter_dm"; + + public override string Id { get; } + + public TwitterDirectMessageId(string id) + => this.Id = id; + } +} diff --git a/OpenTween/Models/TwitterPostFactory.cs b/OpenTween/Models/TwitterPostFactory.cs index b792b4bda..ab9676134 100644 --- a/OpenTween/Models/TwitterPostFactory.cs +++ b/OpenTween/Models/TwitterPostFactory.cs @@ -58,174 +58,127 @@ public PostClass CreateFromStatus( bool favTweet = false ) { - var post = new PostClass(); - TwitterEntities entities; - string sourceHtml; + var statusUser = status.User ?? TwitterUser.CreateUnknownUser(); - post.StatusId = status.Id; + // リツイートでない場合は null + var retweetedStatus = (TwitterStatus?)null; + var retweeterUser = (TwitterUser?)null; 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; - } + // リツイート元のツイート + retweetedStatus = status.RetweetedStatus; + // リツイートを行ったユーザー + retweeterUser = statusUser; } - 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 originalStatus = retweetedStatus ?? status; + var originalStatusUser = originalStatus.User ?? TwitterUser.CreateUnknownUser(); - // 以下、ユーザー情報 - 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"; - } + bool isFav = favTweet; + if (isFav == false) + { + // 幻覚fav対策 (8a5717dd のコミット参照) + var favTab = this.tabinfo.FavoriteTab; + isFav = favTab.Contains(new TwitterStatusId(originalStatus.IdStr)); } - // HTMLに整形 - var textFromApi = post.TextFromApi; - var quotedStatusLink = (status.RetweetedStatus ?? status).QuotedStatusPermalink; + var geo = (PostClass.StatusGeo?)null; + if (originalStatus.Coordinates != null) + geo = new(originalStatus.Coordinates.Coordinates[0], originalStatus.Coordinates.Coordinates[1]); + + var entities = originalStatus.MergedEntities; + var quotedStatusLink = originalStatus.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"); + // HTMLに整形 + var text = CreateHtmlAnchor(originalStatus.FullText, entities, quotedStatusLink); - this.ExtractEntities(entities, post.ReplyToList, post.Media); + var textFromApi = this.ReplaceTextFromApi(originalStatus.FullText, entities, quotedStatusLink); + textFromApi = WebUtility.HtmlDecode(textFromApi); + textFromApi = textFromApi.Replace("<3", "\u2661"); - post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink) - .Where(x => x != post.StatusId && x != post.RetweetedId) - .Distinct().ToArray(); + var accessibleText = CreateAccessibleText(originalStatus.FullText, entities, originalStatus.QuotedStatus, quotedStatusLink); + accessibleText = WebUtility.HtmlDecode(accessibleText); + accessibleText = accessibleText.Replace("<3", "\u2661"); - post.ExpandedUrls = entities.OfType() + var (replyToList, media) = this.ExtractEntities(entities); + + var quoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink) + .Where(x => x.Id != status.IdStr && x.Id != originalStatus.IdStr) + .Distinct() + .ToArray(); + + var 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; + if (text == textFromApi) + text = textFromApi; + + if (accessibleText == textFromApi) + accessibleText = 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; + var screenName = string.Intern(originalStatusUser.ScreenName); + var nickname = string.Intern(originalStatusUser.Name); + var imageUrl = string.Intern(originalStatusUser.ProfileImageUrlHttps); // Source整形 - var (sourceText, sourceUri) = ParseSource(sourceHtml); - post.Source = string.Intern(sourceText); - post.SourceUri = sourceUri; + var (sourceText, sourceUri) = ParseSource(originalStatus.Source); - post.IsReply = post.RetweetedId == null && post.ReplyToList.Any(x => x.UserId == selfUserId); - post.IsExcludeReply = false; + var isOwl = false; + if (followerIds.Count > 0) + isOwl = !followerIds.Contains(originalStatusUser.Id); - if (post.IsMe) - { - post.IsOwl = false; - } - else - { - if (followerIds.Count > 0) - post.IsOwl = !followerIds.Contains(post.UserId); - } + var createdAtForSorting = ParseDateTimeFromSnowflakeId(status.Id, status.CreatedAt); + var createdAt = retweetedStatus != null + ? ParseDateTimeFromSnowflakeId(retweetedStatus.Id, retweetedStatus.CreatedAt) + : createdAtForSorting; - post.IsDm = false; - return post; + return new() + { + // status から生成 + StatusId = new TwitterStatusId(status.IdStr), + CreatedAtForSorting = createdAtForSorting, + IsMe = statusUser.Id == selfUserId, + + // originalStatus から生成 + CreatedAt = createdAt, + Text = text, + TextFromApi = textFromApi, + AccessibleText = accessibleText, + QuoteStatusIds = quoteStatusIds, + ExpandedUrls = expandedUrls, + ReplyToList = replyToList, + Media = media, + PostGeo = geo, + Source = string.Intern(sourceText), + SourceUri = sourceUri, + IsFav = isFav, + IsReply = retweetedStatus != null && replyToList.Any(x => x.UserId == selfUserId), + InReplyToStatusId = originalStatus.InReplyToStatusIdStr != null ? new TwitterStatusId(originalStatus.InReplyToStatusIdStr) : null, + InReplyToUser = originalStatus.InReplyToScreenName, + InReplyToUserId = originalStatus.InReplyToUserId, + + // originalStatusUser から生成 + UserId = originalStatusUser.Id, + ScreenName = screenName, + Nickname = nickname, + ImageUrl = imageUrl, + IsProtect = originalStatusUser.Protected, + IsOwl = isOwl, + + // retweetedStatus から生成 + RetweetedId = retweetedStatus != null ? new TwitterStatusId(retweetedStatus.IdStr) : null, + + // retweeterUser から生成 + RetweetedBy = retweeterUser != null ? string.Intern(retweeterUser.ScreenName) : null, + RetweetedByUserId = retweeterUser?.Id, + }; } public PostClass CreateFromDirectMessageEvent( @@ -235,13 +188,11 @@ public PostClass CreateFromDirectMessageEvent( 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 createdAt = DateTimeUtc.UnixEpoch + TimeSpan.FromTicks(timestamp * TimeSpan.TicksPerMillisecond); + // 本文 - var textFromApi = eventItem.MessageCreate.MessageData.Text; + var rawText = eventItem.MessageCreate.MessageData.Text; var entities = eventItem.MessageCreate.MessageData.Entities; var mediaEntity = eventItem.MessageCreate.MessageData.Attachment?.Media; @@ -250,84 +201,86 @@ long selfUserId 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; + var text = CreateHtmlAnchor(rawText, entities, quotedStatusLink: null); - this.ExtractEntities(entities, post.ReplyToList, post.Media); + var textFromApi = this.ReplaceTextFromApi(rawText, entities, quotedStatusLink: null); + textFromApi = WebUtility.HtmlDecode(textFromApi); + textFromApi = textFromApi.Replace("<3", "\u2661"); - post.QuoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null) - .Distinct().ToArray(); + var accessibleText = CreateAccessibleText(rawText, entities, quotedStatus: null, quotedStatusLink: null); + accessibleText = WebUtility.HtmlDecode(accessibleText); + accessibleText = accessibleText.Replace("<3", "\u2661"); - post.ExpandedUrls = entities.OfType() + var (replyToList, media) = this.ExtractEntities(entities); + + var quoteStatusIds = GetQuoteTweetStatusIds(entities, quotedStatusLink: null) + .Distinct() + .ToArray(); + + var 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; - } + var senderIsMe = eventItem.MessageCreate.SenderId == selfUserId.ToString(CultureInfo.InvariantCulture); + var displayUserId = senderIsMe + ? eventItem.MessageCreate.Target.RecipientId + : eventItem.MessageCreate.SenderId; - 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"; - } + if (!users.TryGetValue(displayUserId, out var displayUser)) + displayUser = TwitterUser.CreateUnknownUser(); // メモリ使用量削減 (同一のテキストであれば同一の string インスタンスを参照させる) - if (post.Text == post.TextFromApi) - post.Text = post.TextFromApi; - if (post.AccessibleText == post.TextFromApi) - post.AccessibleText = post.TextFromApi; + if (text == textFromApi) + text = textFromApi; + if (accessibleText == textFromApi) + accessibleText = textFromApi; // 他の発言と重複しやすい (共通化できる) 文字列は string.Intern を通す - post.ScreenName = string.Intern(post.ScreenName); - post.Nickname = string.Intern(post.Nickname); - post.ImageUrl = string.Intern(post.ImageUrl); + var screenName = string.Intern(displayUser.ScreenName); + var nickname = string.Intern(displayUser.Name); + var imageUrl = string.Intern(displayUser.ProfileImageUrlHttps); + var source = (string?)null; + var sourceUri = (Uri?)null; var appId = eventItem.MessageCreate.SourceAppId; if (appId != null && apps.TryGetValue(appId, out var app)) { - post.Source = string.Intern(app.Name); + source = string.Intern(app.Name); try { - post.SourceUri = new Uri(SourceUriBase, app.Url); + sourceUri = new Uri(SourceUriBase, app.Url); } catch (UriFormatException) { } } - post.IsReply = false; - post.IsExcludeReply = false; - post.IsDm = true; - - return post; + return new() + { + StatusId = new TwitterDirectMessageId(eventItem.Id), + IsDm = true, + CreatedAt = createdAt, + Text = text, + TextFromApi = textFromApi, + AccessibleText = accessibleText, + QuoteStatusIds = quoteStatusIds, + ExpandedUrls = expandedUrls, + ReplyToList = replyToList, + Media = media, + Source = source ?? "", + SourceUri = sourceUri, + + // displayUser から生成 + UserId = displayUser.Id, + ScreenName = screenName, + Nickname = nickname, + ImageUrl = imageUrl, + IsProtect = displayUser.Protected, + IsMe = senderIsMe, + IsOwl = !senderIsMe, + }; } private string ReplaceTextFromApi(string text, TwitterEntities? entities, TwitterQuotedStatusPermalink? quotedStatusLink) @@ -356,10 +309,13 @@ private string ReplaceTextFromApi(string text, TwitterEntities? entities, Twitte return text; } - private void ExtractEntities(TwitterEntities? entities, List<(long UserId, string ScreenName)> atList, List media) + private (List<(long UserId, string ScreenName)> ReplyToList, List Media) ExtractEntities(TwitterEntities? entities) { + var atList = new List<(long UserId, string ScreenName)>(); + var media = new List(); + if (entities == null) - return; + return (atList, media); if (entities.Hashtags != null) { @@ -377,23 +333,22 @@ private void ExtractEntities(TwitterEntities? entities, List<(long UserId, strin if (entities.Media != null) { - if (media != null) + foreach (var ent in entities.Media) { - foreach (var ent in entities.Media) - { - if (media.Any(x => x.Url == ent.MediaUrlHttps)) - continue; + 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 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); - } + var mediaInfo = new MediaInfo(ent.MediaUrlHttps, ent.AltText, videoUrl); + media.Add(mediaInfo); } } + + return (atList, media); } private static string CreateAccessibleText(string text, TwitterEntities? entities, TwitterStatus? quotedStatus, TwitterQuotedStatusPermalink? quotedStatusLink) @@ -452,12 +407,12 @@ internal static string CreateHtmlAnchor(string text, TwitterEntities entities, T // 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 = 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}", + text += string.Format(""" {1}""", WebUtility.HtmlEncode(quotedStatusLink.Expanded), WebUtility.HtmlEncode(quotedStatusLink.Display)); } @@ -516,7 +471,7 @@ public static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml // sourceHtmlの例: Twitter Web Client - var match = Regex.Match(sourceHtml, "^.+?)\".*?>(?.+)$", RegexOptions.IgnoreCase); + var match = Regex.Match(sourceHtml, """^(?.+)$""", RegexOptions.IgnoreCase); if (match.Success) { sourceText = WebUtility.HtmlDecode(match.Groups["text"].Value); @@ -542,7 +497,7 @@ public static (string SourceText, Uri? SourceUri) ParseSource(string? sourceHtml /// /// ツイートに含まれる引用ツイートのURLからステータスIDを抽出 /// - public static IEnumerable GetQuoteTweetStatusIds(IEnumerable? entities, TwitterQuotedStatusPermalink? quotedStatusLink) + public static IEnumerable GetQuoteTweetStatusIds(IEnumerable? entities, TwitterQuotedStatusPermalink? quotedStatusLink) { entities ??= Enumerable.Empty(); @@ -554,17 +509,29 @@ public static IEnumerable GetQuoteTweetStatusIds(IEnumerable GetQuoteTweetStatusIds(IEnumerable 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; - } + yield return new(match.Groups["StatusId"].Value); } } + + public static readonly DateTimeUtc TwitterEpoch = DateTimeUtc.FromUnixTimeMilliseconds(1288834974657L); + + public static DateTimeUtc ParseDateTimeFromSnowflakeId(long statusId, string createdAtStr) + { + // status_id からミリ秒単位の日時を算出する + var timestampInMs = TwitterEpoch + TimeSpan.FromMilliseconds(statusId >> 22); + + // 通常の方法で得た秒精度の日時と比較して 1 秒未満の差であれば timestampInMs の値を採用する + // (Snowflake 導入以前の ID や仕様変更によりこの計算式が使えなくなった場合の対策) + var createdAtFromStr = MyCommon.DateTimeParse(createdAtStr); + var correct = (timestampInMs - createdAtFromStr).Duration() < TimeSpan.FromSeconds(1); + + return correct ? timestampInMs : createdAtFromStr; + } } } diff --git a/OpenTween/Models/TwitterStatusId.cs b/OpenTween/Models/TwitterStatusId.cs new file mode 100644 index 000000000..baa279588 --- /dev/null +++ b/OpenTween/Models/TwitterStatusId.cs @@ -0,0 +1,50 @@ +// OpenTween - Client of Twitter +// Copyright (c) 2023 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.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace OpenTween.Models +{ + public class TwitterStatusId : PostId + { + public override string IdType => "twitter_status"; + + public override string Id { get; } + + public TwitterStatusId(string id) + => this.Id = id; + + public TwitterStatusId(long id) + => this.Id = id.ToString(); + } + + public static class TwitterStatusIdExtension + { + public static TwitterStatusId ToTwitterStatusId(this PostId postId) + => postId is TwitterStatusId statusId ? statusId : throw new InvalidOperationException("Cannot convert to twitter status_id."); + } +} diff --git a/OpenTween/MyCommon.cs b/OpenTween/MyCommon.cs index caf7b5a3e..5e51960fe 100644 --- a/OpenTween/MyCommon.cs +++ b/OpenTween/MyCommon.cs @@ -845,14 +845,12 @@ public static string GetReadableVersion(Version version) public static string GetStatusUrl(PostClass post) { - if (post.RetweetedId == null) - return GetStatusUrl(post.ScreenName, post.StatusId); - else - return GetStatusUrl(post.ScreenName, post.RetweetedId.Value); + var statusId = post.RetweetedId ?? post.StatusId; + return GetStatusUrl(post.ScreenName, statusId.ToTwitterStatusId()); } - public static string GetStatusUrl(string screenName, long statusId) - => TwitterUrl + screenName + "/status/" + statusId; + public static string GetStatusUrl(string screenName, TwitterStatusId statusId) + => TwitterUrl + screenName + "/status/" + statusId.Id; /// /// 指定された IDictionary を元にクエリ文字列を生成します diff --git a/OpenTween/OpenTween.csproj b/OpenTween/OpenTween.csproj index 29b33ed36..b756c986d 100644 --- a/OpenTween/OpenTween.csproj +++ b/OpenTween/OpenTween.csproj @@ -4,7 +4,7 @@ WinExe true net48 - 10.0 + 11.0 bin\$(Configuration)\ true false diff --git a/OpenTween/PostStatusParams.cs b/OpenTween/PostStatusParams.cs index 971e226d9..d7af5bc9c 100644 --- a/OpenTween/PostStatusParams.cs +++ b/OpenTween/PostStatusParams.cs @@ -27,6 +27,7 @@ using System.Text; using System.Threading.Tasks; using OpenTween.Connection; +using OpenTween.Models; namespace OpenTween { @@ -34,7 +35,7 @@ public class PostStatusParams { public string Text { get; set; } = ""; - public long? InReplyToStatusId { get; set; } + public PostId? InReplyToStatusId { get; set; } public IReadOnlyList MediaIds { get; set; } = Array.Empty(); diff --git a/OpenTween/Properties/AssemblyInfo.cs b/OpenTween/Properties/AssemblyInfo.cs index 66fc212c8..f09225087 100644 --- a/OpenTween/Properties/AssemblyInfo.cs +++ b/OpenTween/Properties/AssemblyInfo.cs @@ -22,7 +22,7 @@ // 次の GUID は、このプロジェクトが COM に公開される場合の、typelib の ID です [assembly: Guid("2d0ae0ba-adac-49a2-9b10-26fd69e695bf")] -[assembly: AssemblyVersion("3.5.0.0")] +[assembly: AssemblyVersion("3.6.0.0")] [assembly: InternalsVisibleTo("OpenTween.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // for Moq diff --git a/OpenTween/Properties/Resources.Designer.cs b/OpenTween/Properties/Resources.Designer.cs index 4c2de913e..0c6fc28c3 100644 --- a/OpenTween/Properties/Resources.Designer.cs +++ b/OpenTween/Properties/Resources.Designer.cs @@ -580,6 +580,10 @@ internal static string ChangeIconToolStripMenuItem_Confirm { /// /// 更新履歴 /// + ///==== Ver 3.6.0(2023/07/05) + /// * NEW: Cookie使用時にgraphqlエンドポイントを使用したリストのタイムライン取得に対応 + /// * FIX: 「このタブの発言をクリア」で参照されなくなった発言分のメモリが開放されない場合がある不具合を修正 + /// ///==== Ver 3.5.0(2023/06/16) /// * CHG: アカウント追加時にOpenTweenのAPIキーによる認可を選択肢に追加 /// * CHG: 非対応のOSを使用している場合に起動時に警告を表示する @@ -589,12 +593,7 @@ internal static string ChangeIconToolStripMenuItem_Confirm { /// ///==== Ver 3.4.0(2023/01/29) /// * NEW: 複数枚の画像を添付する際に画像の削除や順序の変更ができるようになりました - /// * FIX: APIアクセス方式の選択画面でのタブオーダーの誤りを修正 - /// - ///==== Ver 3.3.0(2023/01/22) - /// * NEW: アカウント追加時にAPIキーを指定可能になりました - /// * CHG: API v2 の使用を設定状態に関わらず無効化しました - /// * FIX: 同 [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 + /// * FIX: [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。 /// internal static string ChangeLog { get { diff --git a/OpenTween/Resources/ChangeLog.txt b/OpenTween/Resources/ChangeLog.txt index 53a1e6496..7d47a6278 100644 --- a/OpenTween/Resources/ChangeLog.txt +++ b/OpenTween/Resources/ChangeLog.txt @@ -1,5 +1,9 @@ 更新履歴 +==== Ver 3.6.0(2023/07/05) + * NEW: Cookie使用時にgraphqlエンドポイントを使用したリストのタイムライン取得に対応 + * FIX: 「このタブの発言をクリア」で参照されなくなった発言分のメモリが開放されない場合がある不具合を修正 + ==== Ver 3.5.0(2023/06/16) * CHG: アカウント追加時にOpenTweenのAPIキーによる認可を選択肢に追加 * CHG: 非対応のOSを使用している場合に起動時に警告を表示する diff --git a/OpenTween/Thumbnail/Services/MetaThumbnailService.cs b/OpenTween/Thumbnail/Services/MetaThumbnailService.cs index a45474cb9..d5efd3c52 100644 --- a/OpenTween/Thumbnail/Services/MetaThumbnailService.cs +++ b/OpenTween/Thumbnail/Services/MetaThumbnailService.cs @@ -42,8 +42,8 @@ public class MetaThumbnailService : IThumbnailService { protected static Regex[] metaPatterns = { - new Regex(".+?)[\"'] (content|value)=[\"'](?[^>]+?)[\"']"), - new Regex("[^>]+?)[\"'] (name|property)=[\"'](?.+?)[\"']"), + new Regex(""".+?)["'] (content|value)=["'](?[^>]+?)["']"""), + new Regex("""[^>]+?)["'] (name|property)=["'](?.+?)["']"""), }; protected static string[] defaultPropertyNames = { "og:image", "twitter:image", "twitter:image:src" }; diff --git a/OpenTween/TimelineListViewState.cs b/OpenTween/TimelineListViewState.cs index 2a746f1e4..9878d5b66 100644 --- a/OpenTween/TimelineListViewState.cs +++ b/OpenTween/TimelineListViewState.cs @@ -46,7 +46,7 @@ public class TimelineListViewState internal readonly record struct ListViewScroll( ScrollLockMode ScrollLockMode, - long? TopItemStatusId + PostId? TopItemStatusId ); internal enum ScrollLockMode @@ -65,9 +65,9 @@ internal enum ScrollLockMode } internal readonly record struct ListViewSelection( - long[] SelectedStatusIds, - long? SelectionMarkStatusId, - long? FocusedStatusId + PostId[] SelectedStatusIds, + PostId? SelectionMarkStatusId, + PostId? FocusedStatusId ); public TimelineListViewState(DetailsListView listView, TabModel tab) @@ -100,7 +100,7 @@ public void RestoreSelection() private ListViewScroll SaveListViewScroll(bool lockScroll) { var lockMode = this.GetScrollLockMode(lockScroll); - long? topItemStatusId = null; + PostId? topItemStatusId = null; if (lockMode == ScrollLockMode.FixedToItem || lockMode == ScrollLockMode.None) { @@ -165,7 +165,7 @@ private ListViewSelection SaveListViewSelection() { return new ListViewSelection { - SelectedStatusIds = Array.Empty(), + SelectedStatusIds = Array.Empty(), SelectionMarkStatusId = null, FocusedStatusId = null, }; @@ -179,18 +179,18 @@ private ListViewSelection SaveListViewSelection() }; } - private long? GetFocusedStatusId() + private PostId? GetFocusedStatusId() { var index = this.listView.FocusedItem?.Index ?? -1; - return index != -1 && index < this.tab.AllCount ? this.tab.GetStatusIdAt(index) : (long?)null; + return index != -1 && index < this.tab.AllCount ? this.tab.GetStatusIdAt(index) : null; } - private long? GetSelectionMarkStatusId() + private PostId? GetSelectionMarkStatusId() { var index = this.listView.SelectionMark; - return index != -1 && index < this.tab.AllCount ? this.tab.GetStatusIdAt(index) : (long?)null; + return index != -1 && index < this.tab.AllCount ? this.tab.GetStatusIdAt(index) : null; } /// @@ -214,7 +214,7 @@ private void RestoreListViewScroll(ListViewScroll listScroll, bool forceScroll) this.listView.EnsureVisible(this.listView.VirtualListSize - 1); break; case ScrollLockMode.FixedToItem: - var topIndex = listScroll.TopItemStatusId != null ? this.tab.IndexOf(listScroll.TopItemStatusId.Value) : -1; + var topIndex = listScroll.TopItemStatusId != null ? this.tab.IndexOf(listScroll.TopItemStatusId) : -1; if (topIndex != -1) { var topItem = this.listView.Items[topIndex]; @@ -255,14 +255,14 @@ private void RestoreListViewSelection(ListViewSelection listSelection) if (listSelection.FocusedStatusId != null) { - var focusedIndex = this.tab.IndexOf(listSelection.FocusedStatusId.Value); + var focusedIndex = this.tab.IndexOf(listSelection.FocusedStatusId); if (focusedIndex != -1) this.listView.Items[focusedIndex].Focused = true; } if (listSelection.SelectionMarkStatusId != null) { - var selectionMarkIndex = this.tab.IndexOf(listSelection.SelectionMarkStatusId.Value); + var selectionMarkIndex = this.tab.IndexOf(listSelection.SelectionMarkStatusId); if (selectionMarkIndex != -1) this.listView.SelectionMark = selectionMarkIndex; } diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs index 9ce577427..842a04655 100644 --- a/OpenTween/Tween.cs +++ b/OpenTween/Tween.cs @@ -98,10 +98,10 @@ public partial class TweenMain : OTBaseForm private readonly object syncObject = new(); // ロック用 private const string DetailHtmlFormatHead = - "" - + "