diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index b8f640b80..a0e9f8f97 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,5 +1,18 @@
更新履歴
+==== Ver 3.13.0(2024/01/27)
+ * NEW: Cookie使用時のReplyタブの更新に対応(/statuses/mentions_timeline.json 廃止に伴う対応)
+ * NEW: Cookie使用時のFavoritesタブの更新に対応
+ * NEW: Cookie使用時のFav追加・削除に対応
+ * NEW: ステータスバーに各タブの更新回数(起動時からの回数)の表示を追加
+ * NEW: 設定画面の更新間隔ページに24時間分の取得回数目安の表示を追加
+ * CHG: 更新間隔の初期設定を変更
+ * FIX: Cookie使用時にツイート検索の言語指定が効かない不具合を修正
+ * FIX: ツイート検索のキーワードを後から変更すると検索結果が表示されない不具合を修正
+ * FIX: Cookie使用時にステータスバーにRecentタブのレートリミットが表示されない不具合を修正
+ * FIX: 取得したツイートの中身が空だった場合のエラー処理を改善
+ * FIX: タイムラインの取得結果にレートリミットに関するメッセージが含まれていた場合はエラーとして扱う
+
==== Ver 3.12.0(2024/01/20)
* NEW: graphqlエンドポイントを使用したホームタイムラインの取得に対応
diff --git a/OpenTween.Tests/Api/GraphQL/FavoriteTweetRequestTest.cs b/OpenTween.Tests/Api/GraphQL/FavoriteTweetRequestTest.cs
new file mode 100644
index 000000000..cef9242bf
--- /dev/null
+++ b/OpenTween.Tests/Api/GraphQL/FavoriteTweetRequestTest.cs
@@ -0,0 +1,78 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Threading.Tasks;
+using Moq;
+using OpenTween.Connection;
+using Xunit;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class FavoriteTweetRequestTest
+ {
+ [Fact]
+ public async Task Send_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/FavoriteTweet.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet"), request.RequestUri);
+ Assert.Equal("""{"variables":{"tweet_id":"12345"},"queryId":"lI07N6Otwv1PhnEgXILM7A"}""", request.JsonString);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new FavoriteTweetRequest
+ {
+ TweetId = new("12345"),
+ };
+
+ await request.Send(mock.Object);
+
+ mock.VerifyAll();
+ }
+
+ [Fact]
+ public async Task Send_AlreadyFavoritedTest()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/FavoriteTweet_AlreadyFavorited.json");
+
+ var mock = new Mock();
+ mock.Setup(x => x.SendAsync(It.IsAny()))
+ .ReturnsAsync(apiResponse);
+
+ var request = new FavoriteTweetRequest
+ {
+ TweetId = new("12345"),
+ };
+
+ // 重複によるエラーレスポンスが返っているが例外を発生させない
+ await request.Send(mock.Object);
+
+ mock.VerifyAll();
+ }
+ }
+}
diff --git a/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs b/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs
index df1fb6efe..4bc2b5622 100644
--- a/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs
+++ b/OpenTween.Tests/Api/GraphQL/HomeLatestTimelineRequestTest.cs
@@ -21,6 +21,7 @@
using System.Threading.Tasks;
using Moq;
+using OpenTween.Api.DataModel;
using OpenTween.Connection;
using Xunit;
@@ -92,5 +93,32 @@ public async Task Send_RequestCursor_Test()
await request.Send(mock.Object);
mock.VerifyAll();
}
+
+ [Fact]
+ public async Task Send_RateLimitTest()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/HomeLatestTimeline_RateLimit.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/lAKISuk_McyDUlhS2Zmv4A/HomeLatestTimeline"), request.RequestUri);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new HomeLatestTimelineRequest();
+
+ var ex = await Assert.ThrowsAsync(
+ () => request.Send(mock.Object)
+ );
+ var errorItem = Assert.Single(ex.Errors);
+ Assert.Equal(TwitterErrorCode.RateLimit, errorItem.Code);
+
+ mock.VerifyAll();
+ }
}
}
diff --git a/OpenTween.Tests/Api/GraphQL/LikesRequestTest.cs b/OpenTween.Tests/Api/GraphQL/LikesRequestTest.cs
new file mode 100644
index 000000000..accc445c9
--- /dev/null
+++ b/OpenTween.Tests/Api/GraphQL/LikesRequestTest.cs
@@ -0,0 +1,95 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Threading.Tasks;
+using Moq;
+using OpenTween.Connection;
+using Xunit;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class LikesRequestTest
+ {
+ [Fact]
+ public async Task Send_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/Likes.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/G_zHbTiwSqLm0TAK_3sNWQ/Likes"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal(2, query.Count);
+ Assert.Equal("""{"userId":"12345","count":20,"includePromotedContent":false,"withClientEventToken":false,"withBirdwatchNotes":false,"withVoice":true,"withV2Timeline":true}""", query["variables"]);
+ Assert.True(query.ContainsKey("features"));
+ Assert.Equal("Likes", request.EndpointName);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new LikesRequest
+ {
+ UserId = "12345",
+ Count = 20,
+ };
+
+ var response = await request.Send(mock.Object);
+ Assert.Single(response.Tweets);
+ Assert.Equal("DAAHCgABGEs2Ve9AAAELAAIAAAATMTc4OTA3OTU2MDM5NDcxNTMyMggAAwAAAAEAAA", response.CursorTop);
+ Assert.Equal("DAAHCgABGEs2Ve8___8LAAIAAAATMTc4OTA3OTU2MDM5NDcxNTMyMggAAwAAAAIAAA", response.CursorBottom);
+
+ mock.VerifyAll();
+ }
+
+ [Fact]
+ public async Task Send_RequestCursor_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/Likes.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/G_zHbTiwSqLm0TAK_3sNWQ/Likes"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal("""{"userId":"12345","count":20,"includePromotedContent":false,"withClientEventToken":false,"withBirdwatchNotes":false,"withVoice":true,"withV2Timeline":true,"cursor":"aaa"}""", query["variables"]);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new LikesRequest
+ {
+ UserId = "12345",
+ Count = 20,
+ Cursor = "aaa",
+ };
+
+ await request.Send(mock.Object);
+ mock.VerifyAll();
+ }
+ }
+}
diff --git a/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs b/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs
index f1958321e..a4ccdac91 100644
--- a/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs
+++ b/OpenTween.Tests/Api/GraphQL/TimelineTweetTest.cs
@@ -188,11 +188,24 @@ public void ToStatus_TweetTombstone_Test()
var rootElm = this.LoadResponseDocument("TimelineTweet_TweetTombstone.json");
var timelineTweet = new TimelineTweet(rootElm);
- Assert.True(timelineTweet.IsTombstone);
+ Assert.False(timelineTweet.IsAvailable);
var ex = Assert.Throws(
() => timelineTweet.ToTwitterStatus()
);
Assert.Equal("This Post is from a suspended account. Learn more", ex.Message);
}
+
+ [Fact]
+ public void ToStatus_EmptyTweet_Test()
+ {
+ var rootElm = this.LoadResponseDocument("TimelineTweet_EmptyTweet.json");
+ var timelineTweet = new TimelineTweet(rootElm);
+
+ Assert.False(timelineTweet.IsAvailable);
+ var ex = Assert.Throws(
+ () => timelineTweet.ToTwitterStatus()
+ );
+ Assert.Equal("Tweet is not available", ex.Message);
+ }
}
}
diff --git a/OpenTween.Tests/Api/GraphQL/UnfavoriteTweetRequestTest.cs b/OpenTween.Tests/Api/GraphQL/UnfavoriteTweetRequestTest.cs
new file mode 100644
index 000000000..d1d75f2e6
--- /dev/null
+++ b/OpenTween.Tests/Api/GraphQL/UnfavoriteTweetRequestTest.cs
@@ -0,0 +1,58 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Threading.Tasks;
+using Moq;
+using OpenTween.Connection;
+using Xunit;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class UnfavoriteTweetRequestTest
+ {
+ [Fact]
+ public async Task Send_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/UnfavoriteTweet.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/graphql/ZYKSe-w7KEslx3JhSIk5LA/UnfavoriteTweet"), request.RequestUri);
+ Assert.Equal("""{"variables":{"tweet_id":"12345"},"queryId":"ZYKSe-w7KEslx3JhSIk5LA"}""", request.JsonString);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new UnfavoriteTweetRequest
+ {
+ TweetId = new("12345"),
+ };
+
+ await request.Send(mock.Object);
+
+ mock.VerifyAll();
+ }
+ }
+}
diff --git a/OpenTween.Tests/Api/TwitterV2/NotificationsMentionsRequestTest.cs b/OpenTween.Tests/Api/TwitterV2/NotificationsMentionsRequestTest.cs
new file mode 100644
index 000000000..d65c7aee4
--- /dev/null
+++ b/OpenTween.Tests/Api/TwitterV2/NotificationsMentionsRequestTest.cs
@@ -0,0 +1,98 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using System.Threading.Tasks;
+using Moq;
+using OpenTween.Api.GraphQL;
+using OpenTween.Connection;
+using Xunit;
+
+namespace OpenTween.Api.TwitterV2
+{
+ public class NotificationsMentionsRequestTest
+ {
+ [Fact]
+ public async Task Send_Test()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/NotificationsMentions.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/2/notifications/mentions.json"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal("20", query["count"]);
+ Assert.DoesNotContain("cursor", query);
+ Assert.Equal("/2/notifications/mentions", request.EndpointName);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new NotificationsMentionsRequest()
+ {
+ Count = 20,
+ };
+
+ var response = await request.Send(mock.Object);
+ var status = Assert.Single(response.Statuses);
+ Assert.Equal("1748671085438988794", status.IdStr);
+ Assert.Equal("40480664", status.User.IdStr);
+
+ Assert.Equal("DAABDAABCgABAAAAAC4B0ZQIAAIAAAACCAADm5udsQgABCaolIMACwACAAAAC0FZMG1xVjB6VEZjAAA", response.CursorTop);
+ Assert.Equal("DAACDAABCgABAAAAAC4B0ZQIAAIAAAACCAADm5udsQgABCaolIMACwACAAAAC0FZMG1xVjB6VEZjAAA", response.CursorBottom);
+
+ mock.VerifyAll();
+ }
+
+ [Fact]
+ public async Task Send_RequestCursorTest()
+ {
+ using var apiResponse = await TestUtils.CreateApiResponse("Resources/Responses/NotificationsMentions.json");
+
+ var mock = new Mock();
+ mock.Setup(x =>
+ x.SendAsync(It.IsAny())
+ )
+ .Callback(x =>
+ {
+ var request = Assert.IsType(x);
+ Assert.Equal(new("https://twitter.com/i/api/2/notifications/mentions.json"), request.RequestUri);
+ var query = request.Query!;
+ Assert.Equal("20", query["count"]);
+ Assert.Equal("aaa", query["cursor"]);
+ Assert.Equal("/2/notifications/mentions", request.EndpointName);
+ })
+ .ReturnsAsync(apiResponse);
+
+ var request = new NotificationsMentionsRequest()
+ {
+ Count = 20,
+ Cursor = "aaa",
+ };
+
+ await request.Send(mock.Object);
+ mock.VerifyAll();
+ }
+ }
+}
diff --git a/OpenTween.Tests/Resources/Responses/FavoriteTweet.json b/OpenTween.Tests/Resources/Responses/FavoriteTweet.json
new file mode 100644
index 000000000..a3c2d33f8
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/FavoriteTweet.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "favorite_tweet": "Done"
+ }
+}
diff --git a/OpenTween.Tests/Resources/Responses/FavoriteTweet_AlreadyFavorited.json b/OpenTween.Tests/Resources/Responses/FavoriteTweet_AlreadyFavorited.json
new file mode 100644
index 000000000..71415d216
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/FavoriteTweet_AlreadyFavorited.json
@@ -0,0 +1,33 @@
+{
+ "errors": [
+ {
+ "message": "Authorization: Actor (uid: 1234567890) has already favorited tweet (tweetId: 1234567890123456789)",
+ "locations": [
+ {
+ "line": 2,
+ "column": 3
+ }
+ ],
+ "path": [
+ "favorite_tweet"
+ ],
+ "extensions": {
+ "name": "AuthorizationError",
+ "source": "Client",
+ "code": 139,
+ "kind": "Permissions",
+ "tracing": {
+ "trace_id": "87bbb9c871e092a6"
+ }
+ },
+ "code": 139,
+ "kind": "Permissions",
+ "name": "AuthorizationError",
+ "source": "Client",
+ "tracing": {
+ "trace_id": "87bbb9c871e092a6"
+ }
+ }
+ ],
+ "data": {}
+}
diff --git a/OpenTween.Tests/Resources/Responses/HomeLatestTimeline_RateLimit.json b/OpenTween.Tests/Resources/Responses/HomeLatestTimeline_RateLimit.json
new file mode 100644
index 000000000..4a651c37b
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/HomeLatestTimeline_RateLimit.json
@@ -0,0 +1,68 @@
+{
+ "data": {
+ "home": {
+ "home_timeline_urt": {
+ "instructions": [
+ {
+ "type": "TimelineAddEntries",
+ "entries": [
+ {
+ "entryId": "messageprompt-1682783911",
+ "sortIndex": "1749988213990096896",
+ "content": {
+ "entryType": "TimelineTimelineItem",
+ "__typename": "TimelineTimelineItem",
+ "itemContent": {
+ "itemType": "TimelineMessagePrompt",
+ "__typename": "TimelineMessagePrompt",
+ "content": {
+ "contentType": "TimelineInlinePrompt",
+ "headerText": "Unlock more posts by subscribing",
+ "bodyText": "You have reached the limit for seeing posts today. Subscribe to see more posts every day.",
+ "primaryButtonAction": {
+ "text": "Subscribe",
+ "action": {
+ "url": "https://twitter.com/i/twitter_blue_sign_up",
+ "dismissOnClick": false
+ }
+ }
+ }
+ },
+ "clientEventInfo": {
+ "component": "verified_prompt",
+ "element": "message"
+ }
+ }
+ },
+ {
+ "entryId": "cursor-top-1749988213990096897",
+ "sortIndex": "1749988213990096897",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAABCgABGEk1AkAAJxEKAAIYSJVb2ltBzQgAAwAAAAEAAA",
+ "cursorType": "Top"
+ }
+ },
+ {
+ "entryId": "cursor-bottom-1749988213990096895",
+ "sortIndex": "1749988213990096895",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAABCgABGEk1Aj____0KAAIYSJVb2ltBzQgAAwAAAAIAAA",
+ "cursorType": "Bottom"
+ }
+ }
+ ]
+ }
+ ],
+ "metadata": {
+ "scribeConfig": {
+ "page": "following"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/OpenTween.Tests/Resources/Responses/Likes.json b/OpenTween.Tests/Resources/Responses/Likes.json
new file mode 100644
index 000000000..454b641ab
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/Likes.json
@@ -0,0 +1,169 @@
+{
+ "data": {
+ "user": {
+ "result": {
+ "__typename": "User",
+ "timeline_v2": {
+ "timeline": {
+ "instructions": [
+ {
+ "type": "TimelineAddEntries",
+ "entries": [
+ {
+ "entryId": "tweet-1616555877875744768",
+ "sortIndex": "1750552622877638656",
+ "content": {
+ "entryType": "TimelineTimelineItem",
+ "__typename": "TimelineTimelineItem",
+ "itemContent": {
+ "itemType": "TimelineTweet",
+ "__typename": "TimelineTweet",
+ "tweet_results": {
+ "result": {
+ "__typename": "Tweet",
+ "rest_id": "1616555877875744768",
+ "core": {
+ "user_results": {
+ "result": {
+ "__typename": "User",
+ "id": "VXNlcjo0MDQ4MDY2NA==",
+ "rest_id": "40480664",
+ "affiliates_highlighted_label": {},
+ "has_graduated_access": false,
+ "is_blue_verified": false,
+ "profile_image_shape": "Circle",
+ "legacy": {
+ "can_dm": false,
+ "can_media_tag": false,
+ "created_at": "Sat May 16 15:20:01 +0000 2009",
+ "default_profile": false,
+ "default_profile_image": false,
+ "description": "",
+ "entities": {
+ "description": {
+ "urls": []
+ },
+ "url": {
+ "urls": [
+ {
+ "display_url": "m.upsilo.net/@upsilon",
+ "expanded_url": "https://m.upsilo.net/@upsilon",
+ "url": "https://t.co/vNMmyHHh15",
+ "indices": [
+ 0,
+ 23
+ ]
+ }
+ ]
+ }
+ },
+ "fast_followers_count": 0,
+ "favourites_count": 215013,
+ "followers_count": 1280,
+ "friends_count": 1,
+ "has_custom_timelines": false,
+ "is_translator": false,
+ "listed_count": 90,
+ "location": "Funabashi, Chiba, Japan",
+ "media_count": 876,
+ "name": "upsilon",
+ "normal_followers_count": 1280,
+ "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": 10080,
+ "translator_type": "regular",
+ "url": "https://t.co/vNMmyHHh15",
+ "verified": false,
+ "want_retweets": false,
+ "withheld_in_countries": []
+ }
+ }
+ }
+ },
+ "unmention_data": {},
+ "edit_control": {
+ "edit_tweet_ids": [
+ "1616555877875744768"
+ ],
+ "editable_until_msecs": "1674253730000",
+ "is_edit_eligible": true,
+ "edits_remaining": "5"
+ },
+ "is_translatable": true,
+ "views": {
+ "count": "1184",
+ "state": "EnabledWithCount"
+ },
+ "source": "OpenTween (dev)",
+ "legacy": {
+ "bookmark_count": 1,
+ "bookmarked": false,
+ "created_at": "Fri Jan 20 21:58:50 +0000 2023",
+ "conversation_id_str": "1616555877875744768",
+ "display_text_range": [
+ 0,
+ 70
+ ],
+ "entities": {
+ "hashtags": [],
+ "symbols": [],
+ "timestamps": [],
+ "urls": [],
+ "user_mentions": []
+ },
+ "favorite_count": 8,
+ "favorited": true,
+ "full_text": "昨日のOpenTweenのリリース、画像投稿周りに結構手を加えたから不具合出てないか心配だったのにそれ関連のエラー報告上がってないの逆に怖い",
+ "is_quote_status": false,
+ "lang": "ja",
+ "quote_count": 0,
+ "reply_count": 0,
+ "retweet_count": 0,
+ "retweeted": false,
+ "user_id_str": "40480664",
+ "id_str": "1616555877875744768"
+ }
+ }
+ },
+ "tweetDisplayType": "Tweet"
+ }
+ }
+ },
+ {
+ "entryId": "cursor-top-1750552622877638657",
+ "sortIndex": "1750552622877638657",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAAHCgABGEs2Ve9AAAELAAIAAAATMTc4OTA3OTU2MDM5NDcxNTMyMggAAwAAAAEAAA",
+ "cursorType": "Top"
+ }
+ },
+ {
+ "entryId": "cursor-bottom-1750552622877638655",
+ "sortIndex": "1750552622877638655",
+ "content": {
+ "entryType": "TimelineTimelineCursor",
+ "__typename": "TimelineTimelineCursor",
+ "value": "DAAHCgABGEs2Ve8___8LAAIAAAATMTc4OTA3OTU2MDM5NDcxNTMyMggAAwAAAAIAAA",
+ "cursorType": "Bottom"
+ }
+ }
+ ]
+ }
+ ],
+ "metadata": {
+ "scribeConfig": {
+ "page": "favorites"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/OpenTween.Tests/Resources/Responses/NotificationsMentions.json b/OpenTween.Tests/Resources/Responses/NotificationsMentions.json
new file mode 100644
index 000000000..e505f6ef1
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/NotificationsMentions.json
@@ -0,0 +1,732 @@
+{
+ "globalObjects": {
+ "users": {
+ "771871124": {
+ "id": 771871124,
+ "id_str": "771871124",
+ "name": "OpenTween 新着コミット",
+ "screen_name": "OpenTweenCommit",
+ "location": null,
+ "description": "最新の開発版OpenTweenは https://t.co/a0mUFAT58Y から試せます",
+ "url": null,
+ "entities": {
+ "description": {
+ "urls": [
+ {
+ "url": "https://t.co/a0mUFAT58Y",
+ "expanded_url": "https://ci.appveyor.com/project/upsilon/opentween/build/artifacts?branch=master",
+ "display_url": "ci.appveyor.com/project/upsilo…",
+ "indices": [
+ 17,
+ 40
+ ]
+ }
+ ]
+ }
+ },
+ "protected": false,
+ "followers_count": 40,
+ "friends_count": 0,
+ "listed_count": 0,
+ "created_at": "Tue Aug 21 17:03:01 +0000 2012",
+ "favourites_count": 0,
+ "utc_offset": null,
+ "time_zone": null,
+ "geo_enabled": false,
+ "verified": false,
+ "statuses_count": 1991,
+ "lang": null,
+ "contributors_enabled": false,
+ "is_translator": false,
+ "is_translation_enabled": false,
+ "profile_background_color": "C0DEED",
+ "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_tile": false,
+ "profile_image_url": "http://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png",
+ "profile_image_url_https": "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png",
+ "profile_link_color": "1DA1F2",
+ "profile_sidebar_border_color": "C0DEED",
+ "profile_sidebar_fill_color": "DDEEF6",
+ "profile_text_color": "333333",
+ "profile_use_background_image": true,
+ "default_profile": true,
+ "default_profile_image": true,
+ "following": null,
+ "follow_request_sent": null,
+ "notifications": null,
+ "blocking": null,
+ "translator_type": "none",
+ "withheld_in_countries": [],
+ "ext_is_blue_verified": false
+ },
+ "40480664": {
+ "id": 40480664,
+ "id_str": "40480664",
+ "name": "upsilon",
+ "screen_name": "kim_upsilon",
+ "location": "Funabashi, Chiba, Japan",
+ "description": null,
+ "url": "https://t.co/vNMmyHHh15",
+ "entities": {
+ "url": {
+ "urls": [
+ {
+ "url": "https://t.co/vNMmyHHh15",
+ "expanded_url": "https://m.upsilo.net/@upsilon",
+ "display_url": "m.upsilo.net/@upsilon",
+ "indices": [
+ 0,
+ 23
+ ]
+ }
+ ]
+ },
+ "description": {
+ "urls": []
+ }
+ },
+ "protected": false,
+ "followers_count": 1281,
+ "friends_count": 1,
+ "listed_count": 90,
+ "created_at": "Sat May 16 15:20:01 +0000 2009",
+ "favourites_count": 215079,
+ "utc_offset": null,
+ "time_zone": null,
+ "geo_enabled": true,
+ "verified": false,
+ "statuses_count": 10081,
+ "lang": null,
+ "contributors_enabled": false,
+ "is_translator": false,
+ "is_translation_enabled": false,
+ "profile_background_color": "CFEB81",
+ "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_tile": false,
+ "profile_image_url": "http://pbs.twimg.com/profile_images/719076434/____normal.png",
+ "profile_image_url_https": "https://pbs.twimg.com/profile_images/719076434/____normal.png",
+ "profile_banner_url": "https://pbs.twimg.com/profile_banners/40480664/1349188016",
+ "profile_link_color": "999900",
+ "profile_sidebar_border_color": "FFFFFF",
+ "profile_sidebar_fill_color": "99FF99",
+ "profile_text_color": "336666",
+ "profile_use_background_image": false,
+ "default_profile": false,
+ "default_profile_image": false,
+ "following": false,
+ "follow_request_sent": null,
+ "notifications": null,
+ "blocking": false,
+ "blocked_by": false,
+ "want_retweets": false,
+ "profile_interstitial_type": "",
+ "translator_type": "regular",
+ "withheld_in_countries": [],
+ "followed_by": false,
+ "ext_is_blue_verified": false,
+ "ext_highlighted_label": {}
+ }
+ },
+ "tweets": {
+ "1748671085438988794": {
+ "created_at": "Sat Jan 20 11:37:30 +0000 2024",
+ "id": 1748671085438988800,
+ "id_str": "1748671085438988794",
+ "full_text": "@OpenTweenCommit test",
+ "truncated": false,
+ "display_text_range": [
+ 17,
+ 21
+ ],
+ "entities": {
+ "hashtags": [],
+ "symbols": [],
+ "user_mentions": [
+ {
+ "screen_name": "OpenTweenCommit",
+ "name": "OpenTween 新着コミット",
+ "id": 771871124,
+ "id_str": "771871124",
+ "indices": [
+ 0,
+ 16
+ ]
+ }
+ ],
+ "urls": []
+ },
+ "source": "Twitter Web App",
+ "in_reply_to_status_id": 1617562124569526300,
+ "in_reply_to_status_id_str": "1617562124569526279",
+ "in_reply_to_user_id": 771871124,
+ "in_reply_to_user_id_str": "771871124",
+ "in_reply_to_screen_name": "OpenTweenCommit",
+ "user_id": 40480664,
+ "user_id_str": "40480664",
+ "geo": null,
+ "coordinates": null,
+ "place": null,
+ "contributors": null,
+ "is_quote_status": false,
+ "retweet_count": 0,
+ "favorite_count": 0,
+ "reply_count": 0,
+ "quote_count": 0,
+ "conversation_id": 1617562124569526300,
+ "conversation_id_str": "1617562124569526279",
+ "conversation_muted": false,
+ "favorited": false,
+ "retweeted": false,
+ "lang": "en",
+ "ext": {
+ "superFollowMetadata": {
+ "r": {
+ "ok": {}
+ },
+ "ttl": -1
+ }
+ }
+ },
+ "1617562124569526279": {
+ "created_at": "Mon Jan 23 16:37:18 +0000 2023",
+ "id": 1617562124569526300,
+ "id_str": "1617562124569526279",
+ "full_text": "Merge pull request #195 from opentween/reorder-in-mediaselector\n https://t.co/U8OpWWyVD6",
+ "truncated": false,
+ "display_text_range": [
+ 0,
+ 92
+ ],
+ "entities": {
+ "hashtags": [],
+ "symbols": [],
+ "user_mentions": [],
+ "urls": [
+ {
+ "url": "https://t.co/U8OpWWyVD6",
+ "expanded_url": "https://github.com/opentween/OpenTween/commit/73079c5ca9bd1c3b9e35613b5050f5ba984b4ccc",
+ "display_url": "github.com/opentween/Open…",
+ "indices": [
+ 69,
+ 92
+ ]
+ }
+ ]
+ },
+ "source": "IFTTT",
+ "in_reply_to_status_id": null,
+ "in_reply_to_status_id_str": null,
+ "in_reply_to_user_id": null,
+ "in_reply_to_user_id_str": null,
+ "in_reply_to_screen_name": null,
+ "user_id": 771871124,
+ "user_id_str": "771871124",
+ "geo": null,
+ "coordinates": null,
+ "place": null,
+ "contributors": null,
+ "is_quote_status": false,
+ "retweet_count": 0,
+ "favorite_count": 0,
+ "reply_count": 1,
+ "quote_count": 0,
+ "conversation_id": 1617562124569526300,
+ "conversation_id_str": "1617562124569526279",
+ "conversation_muted": false,
+ "favorited": false,
+ "retweeted": false,
+ "possibly_sensitive": false,
+ "card": {
+ "name": "summary_large_image",
+ "url": "https://t.co/U8OpWWyVD6",
+ "card_type_url": "http://card-type-url-is-deprecated.invalid",
+ "binding_values": {
+ "vanity_url": {
+ "type": "STRING",
+ "string_value": "github.com",
+ "scribe_key": "vanity_url"
+ },
+ "domain": {
+ "type": "STRING",
+ "string_value": "github.com"
+ },
+ "site": {
+ "type": "USER",
+ "user_value": {
+ "id_str": "13334762",
+ "path": []
+ },
+ "scribe_key": "publisher_id"
+ },
+ "title": {
+ "type": "STRING",
+ "string_value": "Merge pull request #195 from opentween/reorder-in-mediaselector · opentween/OpenTween@73079c5"
+ },
+ "summary_photo_image_alt_text": {
+ "type": "STRING",
+ "string_value": "MediaSelectorに追加したメディアの順序変更・削除に対応"
+ },
+ "photo_image_full_size_alt_text": {
+ "type": "STRING",
+ "string_value": "MediaSelectorに追加したメディアの順序変更・削除に対応"
+ },
+ "description": {
+ "type": "STRING",
+ "string_value": "MediaSelectorに追加したメディアの順序変更・削除に対応"
+ },
+ "thumbnail_image_small": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=144x144",
+ "width": 144,
+ "height": 72,
+ "alt": null
+ }
+ },
+ "thumbnail_image": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=400x400",
+ "width": 400,
+ "height": 200,
+ "alt": null
+ }
+ },
+ "thumbnail_image_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=600x600",
+ "width": 600,
+ "height": 300,
+ "alt": null
+ }
+ },
+ "thumbnail_image_x_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=png&name=2048x2048_2_exp",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "thumbnail_image_color": {
+ "type": "IMAGE_COLOR",
+ "image_color_value": {
+ "palette": [
+ {
+ "percentage": 90.7,
+ "rgb": {
+ "red": 255,
+ "green": 255,
+ "blue": 255
+ }
+ },
+ {
+ "percentage": 4.25,
+ "rgb": {
+ "red": 119,
+ "green": 123,
+ "blue": 128
+ }
+ },
+ {
+ "percentage": 3.15,
+ "rgb": {
+ "red": 23,
+ "green": 135,
+ "blue": 1
+ }
+ },
+ {
+ "percentage": 1.72,
+ "rgb": {
+ "red": 118,
+ "green": 184,
+ "blue": 105
+ }
+ },
+ {
+ "percentage": 0.12,
+ "rgb": {
+ "red": 240,
+ "green": 202,
+ "blue": 206
+ }
+ }
+ ]
+ }
+ },
+ "thumbnail_image_original": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=orig",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "summary_photo_image_small": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=386x202",
+ "width": 386,
+ "height": 202,
+ "alt": null
+ }
+ },
+ "summary_photo_image": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=600x314",
+ "width": 600,
+ "height": 314,
+ "alt": null
+ }
+ },
+ "summary_photo_image_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=800x419",
+ "width": 800,
+ "height": 419,
+ "alt": null
+ }
+ },
+ "summary_photo_image_x_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=png&name=2048x2048_2_exp",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "summary_photo_image_color": {
+ "type": "IMAGE_COLOR",
+ "image_color_value": {
+ "palette": [
+ {
+ "percentage": 90.7,
+ "rgb": {
+ "red": 255,
+ "green": 255,
+ "blue": 255
+ }
+ },
+ {
+ "percentage": 4.25,
+ "rgb": {
+ "red": 119,
+ "green": 123,
+ "blue": 128
+ }
+ },
+ {
+ "percentage": 3.15,
+ "rgb": {
+ "red": 23,
+ "green": 135,
+ "blue": 1
+ }
+ },
+ {
+ "percentage": 1.72,
+ "rgb": {
+ "red": 118,
+ "green": 184,
+ "blue": 105
+ }
+ },
+ {
+ "percentage": 0.12,
+ "rgb": {
+ "red": 240,
+ "green": 202,
+ "blue": 206
+ }
+ }
+ ]
+ }
+ },
+ "summary_photo_image_original": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=orig",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "photo_image_full_size_small": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=386x202",
+ "width": 386,
+ "height": 202,
+ "alt": null
+ }
+ },
+ "photo_image_full_size": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=600x314",
+ "width": 600,
+ "height": 314,
+ "alt": null
+ }
+ },
+ "photo_image_full_size_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=800x419",
+ "width": 800,
+ "height": 419,
+ "alt": null
+ }
+ },
+ "photo_image_full_size_x_large": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=png&name=2048x2048_2_exp",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "photo_image_full_size_color": {
+ "type": "IMAGE_COLOR",
+ "image_color_value": {
+ "palette": [
+ {
+ "percentage": 90.7,
+ "rgb": {
+ "red": 255,
+ "green": 255,
+ "blue": 255
+ }
+ },
+ {
+ "percentage": 4.25,
+ "rgb": {
+ "red": 119,
+ "green": 123,
+ "blue": 128
+ }
+ },
+ {
+ "percentage": 3.15,
+ "rgb": {
+ "red": 23,
+ "green": 135,
+ "blue": 1
+ }
+ },
+ {
+ "percentage": 1.72,
+ "rgb": {
+ "red": 118,
+ "green": 184,
+ "blue": 105
+ }
+ },
+ {
+ "percentage": 0.12,
+ "rgb": {
+ "red": 240,
+ "green": 202,
+ "blue": 206
+ }
+ }
+ ]
+ }
+ },
+ "photo_image_full_size_original": {
+ "type": "IMAGE",
+ "image_value": {
+ "url": "https://pbs.twimg.com/card_img/1746775136962027520/BepKshBj?format=jpg&name=orig",
+ "width": 1200,
+ "height": 600,
+ "alt": null
+ }
+ },
+ "card_url": {
+ "type": "STRING",
+ "string_value": "https://t.co/U8OpWWyVD6",
+ "scribe_key": "card_url"
+ }
+ },
+ "users": {
+ "13334762": {
+ "id": 13334762,
+ "id_str": "13334762",
+ "name": "GitHub",
+ "screen_name": "github",
+ "location": "San Francisco, CA",
+ "description": "The AI-powered developer platform to build, scale, and deliver secure software.",
+ "url": "https://t.co/bbJgfyzcJR",
+ "entities": {
+ "url": {
+ "urls": [
+ {
+ "url": "https://t.co/bbJgfyzcJR",
+ "expanded_url": "http://github.com",
+ "display_url": "github.com",
+ "indices": [
+ 0,
+ 23
+ ]
+ }
+ ]
+ },
+ "description": {
+ "urls": []
+ }
+ },
+ "protected": false,
+ "followers_count": 2550286,
+ "friends_count": 336,
+ "listed_count": 18218,
+ "created_at": "Mon Feb 11 04:41:50 +0000 2008",
+ "favourites_count": 8192,
+ "utc_offset": null,
+ "time_zone": null,
+ "geo_enabled": true,
+ "verified": false,
+ "statuses_count": 8814,
+ "lang": null,
+ "contributors_enabled": false,
+ "is_translator": false,
+ "is_translation_enabled": false,
+ "profile_background_color": "EEEEEE",
+ "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
+ "profile_background_tile": false,
+ "profile_image_url": "http://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png",
+ "profile_image_url_https": "https://pbs.twimg.com/profile_images/1633247750010830848/8zfRrYjA_normal.png",
+ "profile_banner_url": "https://pbs.twimg.com/profile_banners/13334762/1692114901",
+ "profile_link_color": "981CEB",
+ "profile_sidebar_border_color": "BBBBBB",
+ "profile_sidebar_fill_color": "DDDDDD",
+ "profile_text_color": "000000",
+ "profile_use_background_image": false,
+ "default_profile": false,
+ "default_profile_image": false,
+ "can_media_tag": null,
+ "following": false,
+ "follow_request_sent": null,
+ "notifications": null,
+ "blocking": false,
+ "blocked_by": false,
+ "profile_interstitial_type": "",
+ "translator_type": "none",
+ "withheld_in_countries": [],
+ "followed_by": false,
+ "ext_is_blue_verified": true,
+ "ext_verified_type": "Business",
+ "ext_highlighted_label": {},
+ "ext": {
+ "highlightedLabel": {
+ "r": {
+ "ok": {}
+ },
+ "ttl": -1
+ }
+ }
+ }
+ },
+ "card_platform": {
+ "platform": {
+ "device": {
+ "name": "Swift",
+ "version": "12"
+ },
+ "audience": {
+ "name": "production",
+ "bucket": null
+ }
+ }
+ }
+ },
+ "lang": "en",
+ "ext": {
+ "superFollowMetadata": {
+ "r": {
+ "ok": {}
+ },
+ "ttl": -1
+ }
+ }
+ }
+ }
+ },
+ "timeline": {
+ "id": "AAAAAC4B0ZQAAAACm5udsSaolIM",
+ "instructions": [
+ {
+ "addEntries": {
+ "entries": [
+ {
+ "entryId": "cursor-top-1705750650164",
+ "sortIndex": "1705750650164",
+ "content": {
+ "operation": {
+ "cursor": {
+ "value": "DAABDAABCgABAAAAAC4B0ZQIAAIAAAACCAADm5udsQgABCaolIMACwACAAAAC0FZMG1xVjB6VEZjAAA",
+ "cursorType": "Top"
+ }
+ }
+ }
+ },
+ {
+ "entryId": "notification-AAAAAC4B0ZQAAAACm5udsSaolIMe0Mhk3Nw",
+ "sortIndex": "1705750650163",
+ "content": {
+ "item": {
+ "content": {
+ "tweet": {
+ "id": "1748671085438988794",
+ "displayType": "Tweet"
+ }
+ },
+ "clientEventInfo": {
+ "component": "urt",
+ "element": "user_replied_to_your_tweet",
+ "details": {
+ "notificationDetails": {
+ "impressionId": "ba99cafcbccaee49f2da523e5b86ac9c",
+ "metadata": "CwABAAAAM2RkMDgyOTNhNjk5OTQzNGQuNDhlZWY4ZWFlMDNlMDlkZDw6ZGQwODI5M2E2OTk5NDM0ZAsAAgAAACNBQUFBQUM0QjBaUUFBQUFDbTV1ZHNTYW9sSU1lME1oazNOdwsAAwAAABQ3NzE4NzExMjQtLTI2NzU3NDczOAoABAAAAAAAAAABDwAFCgAAAAIYRIcWXJZx-hZyvC6dF8AHCwAGAAAAGnVzZXJfcmVwbGllZF90b195b3VyX3R3ZWV0DwAHCwAAAAEAAAAUNzcxODcxMTI0LS0yNjc1NzQ3MzgA"
+ }
+ }
+ }
+ }
+ }
+ },
+ {
+ "entryId": "cursor-bottom-1705750650162",
+ "sortIndex": "1705750650162",
+ "content": {
+ "operation": {
+ "cursor": {
+ "value": "DAACDAABCgABAAAAAC4B0ZQIAAIAAAACCAADm5udsQgABCaolIMACwACAAAAC0FZMG1xVjB6VEZjAAA",
+ "cursorType": "Bottom"
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ {
+ "clearEntriesUnreadState": {}
+ },
+ {
+ "markEntriesUnreadGreaterThanSortIndex": {
+ "sortIndex": "1697974584014"
+ }
+ }
+ ]
+ }
+}
diff --git a/OpenTween.Tests/Resources/Responses/TimelineTweet_EmptyTweet.json b/OpenTween.Tests/Resources/Responses/TimelineTweet_EmptyTweet.json
new file mode 100644
index 000000000..70b98651c
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/TimelineTweet_EmptyTweet.json
@@ -0,0 +1,6 @@
+{
+ "itemType": "TimelineTweet",
+ "__typename": "TimelineTweet",
+ "tweet_results": {},
+ "tweetDisplayType": "Tweet"
+}
diff --git a/OpenTween.Tests/Resources/Responses/UnfavoriteTweet.json b/OpenTween.Tests/Resources/Responses/UnfavoriteTweet.json
new file mode 100644
index 000000000..11e92e2a4
--- /dev/null
+++ b/OpenTween.Tests/Resources/Responses/UnfavoriteTweet.json
@@ -0,0 +1,5 @@
+{
+ "data": {
+ "unfavorite_tweet": "Done"
+ }
+}
diff --git a/OpenTween.Tests/Setting/Panel/GraphqlRequestEstimationTest.cs b/OpenTween.Tests/Setting/Panel/GraphqlRequestEstimationTest.cs
new file mode 100644
index 000000000..131b54aa4
--- /dev/null
+++ b/OpenTween.Tests/Setting/Panel/GraphqlRequestEstimationTest.cs
@@ -0,0 +1,68 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using Xunit;
+
+namespace OpenTween.Setting.Panel
+{
+ public class GraphqlRequestEstimationTest
+ {
+ [Fact]
+ public void CalcDailyRequestCount_Test()
+ {
+ var tabCounts = new TabTypeAggregation.Result(
+ HomeTabs: 1,
+ MentionsTabs: 1,
+ DMTabs: 1,
+ SearchTabs: 2,
+ ListTabs: 1,
+ UserTabs: 1
+ );
+ var intervals = new GraphqlRequestEstimation.Intervals(
+ Home: 90, // 960 requests / day
+ Search: 180, // 480 requests / day
+ List: 180, // 480 requests / day
+ User: 600 // 144 requests / day
+ );
+ Assert.Equal(2544, GraphqlRequestEstimation.CalcDailyRequestCount(intervals, tabCounts));
+ }
+
+ [Fact]
+ public void CalcDailyRequestCount_DisableAutoRefreshTest()
+ {
+ var tabCounts = new TabTypeAggregation.Result(
+ HomeTabs: 1,
+ MentionsTabs: 1,
+ DMTabs: 1,
+ SearchTabs: 2,
+ ListTabs: 1,
+ UserTabs: 1
+ );
+ var intervals = new GraphqlRequestEstimation.Intervals(
+ Home: 0, // 0 は自動更新の無効化を表す
+ Search: 0,
+ List: 0,
+ User: 0
+ );
+ Assert.Equal(0, GraphqlRequestEstimation.CalcDailyRequestCount(intervals, tabCounts));
+ }
+ }
+}
diff --git a/OpenTween.Tests/Setting/Panel/TabTypeAggregationTest.cs b/OpenTween.Tests/Setting/Panel/TabTypeAggregationTest.cs
new file mode 100644
index 000000000..074f50fb5
--- /dev/null
+++ b/OpenTween.Tests/Setting/Panel/TabTypeAggregationTest.cs
@@ -0,0 +1,48 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+using OpenTween.Models;
+using Xunit;
+
+namespace OpenTween.Setting.Panel
+{
+ public class TabTypeAggregationTest
+ {
+ [Fact]
+ public void Aggregate_Test()
+ {
+ var tabinfo = new TabInformations();
+ tabinfo.AddDefaultTabs();
+ tabinfo.AddTab(new PublicSearchTabModel("search1"));
+ tabinfo.AddTab(new PublicSearchTabModel("search2"));
+
+ var expected = new TabTypeAggregation.Result(
+ HomeTabs: 1,
+ MentionsTabs: 1,
+ DMTabs: 1,
+ SearchTabs: 2,
+ ListTabs: 0,
+ UserTabs: 0
+ );
+ Assert.Equal(expected, TabTypeAggregation.Aggregate(tabinfo));
+ }
+ }
+}
diff --git a/OpenTween.Tests/TweenMainTest.cs b/OpenTween.Tests/TweenMainTest.cs
index 573670771..526fbf025 100644
--- a/OpenTween.Tests/TweenMainTest.cs
+++ b/OpenTween.Tests/TweenMainTest.cs
@@ -59,6 +59,8 @@ private void UsingTweenMain(Action func)
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.SetField);
field.SetValue(null, tabinfo);
+ tabinfo.AddDefaultTabs();
+
using var tweenMain = new TweenMain(settings, tabinfo, accounts, imageCache, iconAssets, thumbnailGenerator);
var context = new TestContext(settings, tabinfo);
diff --git a/OpenTween/Api/GraphQL/ErrorResponse.cs b/OpenTween/Api/GraphQL/ErrorResponse.cs
index 4f778a081..2a8e6d446 100644
--- a/OpenTween/Api/GraphQL/ErrorResponse.cs
+++ b/OpenTween/Api/GraphQL/ErrorResponse.cs
@@ -30,6 +30,7 @@
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
+using OpenTween.Api.DataModel;
namespace OpenTween.Api.GraphQL
{
@@ -63,5 +64,25 @@ public static void ThrowIfError(XElement rootElm)
throw new WebApiException(messageText, responseJson);
}
+
+ public static void ThrowIfContainsRateLimitMessage(XElement rootElm)
+ {
+ var messageElm = rootElm.XPathSelectElement("//itemContent[itemType[text()='TimelineMessagePrompt']]");
+ if (messageElm == null)
+ return;
+
+ var bodyText = messageElm.XPathSelectElement("content/bodyText")?.Value ?? "";
+ if (bodyText.StartsWith("You have reached the limit"))
+ {
+ var error = new TwitterError
+ {
+ Errors = new[]
+ {
+ new TwitterErrorItem { Code = TwitterErrorCode.RateLimit, Message = "" },
+ },
+ };
+ throw new TwitterApiException(0, error, "");
+ }
+ }
}
}
diff --git a/OpenTween/Api/GraphQL/FavoriteTweetRequest.cs b/OpenTween/Api/GraphQL/FavoriteTweetRequest.cs
new file mode 100644
index 000000000..1abe7ecf3
--- /dev/null
+++ b/OpenTween/Api/GraphQL/FavoriteTweetRequest.cs
@@ -0,0 +1,73 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Threading.Tasks;
+using OpenTween.Connection;
+using OpenTween.Models;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class FavoriteTweetRequest
+ {
+ private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet");
+
+ public required TwitterStatusId TweetId { get; set; }
+
+ public string CreateRequestBody()
+ {
+ return $$"""
+ {"variables":{"tweet_id":"{{JsonUtils.EscapeJsonString(this.TweetId.Id)}}"},"queryId":"lI07N6Otwv1PhnEgXILM7A"}
+ """;
+ }
+
+ public async Task Send(IApiConnection apiConnection)
+ {
+ var request = new PostJsonRequest
+ {
+ RequestUri = EndpointUri,
+ JsonString = this.CreateRequestBody(),
+ };
+
+ using var response = await apiConnection.SendAsync(request)
+ .ConfigureAwait(false);
+
+ var rootElm = await response.ReadAsJsonXml()
+ .ConfigureAwait(false);
+
+ try
+ {
+ ErrorResponse.ThrowIfError(rootElm);
+ }
+ catch (WebApiException ex)
+ when (ex.Message.Contains("has already favorited"))
+ {
+ // 重複リクエストの場合は成功と見なす
+ }
+
+ return new();
+ }
+
+ public readonly record struct FavoriteTweetResponse();
+ }
+}
diff --git a/OpenTween/Api/GraphQL/HomeLatestTimelineRequest.cs b/OpenTween/Api/GraphQL/HomeLatestTimelineRequest.cs
index d11c0ca77..e9fb72ed3 100644
--- a/OpenTween/Api/GraphQL/HomeLatestTimelineRequest.cs
+++ b/OpenTween/Api/GraphQL/HomeLatestTimelineRequest.cs
@@ -24,7 +24,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
-using System.Xml.XPath;
using OpenTween.Connection;
namespace OpenTween.Api.GraphQL
@@ -69,13 +68,7 @@ public async Task Send(IApiConnection apiConnection)
var rootElm = await response.ReadAsJsonXml()
.ConfigureAwait(false);
- ErrorResponse.ThrowIfError(rootElm);
-
- var tweets = TimelineTweet.ExtractTimelineTweets(rootElm);
- var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value;
- var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value;
-
- return new(tweets, cursorTop, cursorBottom);
+ return TimelineResponseParser.Parse(rootElm);
}
}
}
diff --git a/OpenTween/Api/GraphQL/LikesRequest.cs b/OpenTween/Api/GraphQL/LikesRequest.cs
new file mode 100644
index 000000000..deda28a08
--- /dev/null
+++ b/OpenTween/Api/GraphQL/LikesRequest.cs
@@ -0,0 +1,81 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using OpenTween.Connection;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class LikesRequest
+ {
+ public static readonly string EndpointName = "Likes";
+
+ private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/G_zHbTiwSqLm0TAK_3sNWQ/Likes");
+
+ public required string UserId { get; set; }
+
+ public int Count { get; set; } = 20;
+
+ public string? Cursor { get; set; }
+
+ public Dictionary CreateParameters()
+ {
+ return new()
+ {
+ ["variables"] = "{" +
+ $@"""userId"":""{JsonUtils.EscapeJsonString(this.UserId)}""," +
+ $@"""count"":{this.Count}," +
+ $@"""includePromotedContent"":false," +
+ $@"""withClientEventToken"":false," +
+ $@"""withBirdwatchNotes"":false," +
+ $@"""withVoice"":true," +
+ $@"""withV2Timeline"":true" +
+ (this.Cursor != null ? $@",""cursor"":""{JsonUtils.EscapeJsonString(this.Cursor)}""" : "") +
+ "}",
+ ["features"] = """
+ {"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,"c9s_tweet_anatomy_moderator_badge_enabled":true,"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":true,"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,"rweb_video_timestamps_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 request = new GetRequest
+ {
+ RequestUri = EndpointUri,
+ Query = this.CreateParameters(),
+ EndpointName = EndpointName,
+ };
+
+ using var response = await apiConnection.SendAsync(request)
+ .ConfigureAwait(false);
+
+ var rootElm = await response.ReadAsJsonXml()
+ .ConfigureAwait(false);
+
+ return TimelineResponseParser.Parse(rootElm);
+ }
+ }
+}
diff --git a/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs
index 3aa306f6f..29d0614b8 100644
--- a/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs
+++ b/OpenTween/Api/GraphQL/ListLatestTweetsTimelineRequest.cs
@@ -24,7 +24,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
-using System.Xml.XPath;
using OpenTween.Connection;
namespace OpenTween.Api.GraphQL
@@ -93,13 +92,7 @@ public async Task Send(IApiConnection apiConnection)
var rootElm = await response.ReadAsJsonXml()
.ConfigureAwait(false);
- ErrorResponse.ThrowIfError(rootElm);
-
- var tweets = TimelineTweet.ExtractTimelineTweets(rootElm);
- var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value;
- var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value;
-
- return new(tweets, cursorTop, cursorBottom);
+ return TimelineResponseParser.Parse(rootElm);
}
}
}
diff --git a/OpenTween/Api/GraphQL/SearchTimelineRequest.cs b/OpenTween/Api/GraphQL/SearchTimelineRequest.cs
index 568371806..9587d8079 100644
--- a/OpenTween/Api/GraphQL/SearchTimelineRequest.cs
+++ b/OpenTween/Api/GraphQL/SearchTimelineRequest.cs
@@ -24,7 +24,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
-using System.Xml.XPath;
using OpenTween.Connection;
namespace OpenTween.Api.GraphQL
@@ -95,13 +94,7 @@ public async Task Send(IApiConnection apiConnection)
var rootElm = await response.ReadAsJsonXml()
.ConfigureAwait(false);
- ErrorResponse.ThrowIfError(rootElm);
-
- var tweets = TimelineTweet.ExtractTimelineTweets(rootElm);
- var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value;
- var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value;
-
- return new(tweets, cursorTop, cursorBottom);
+ return TimelineResponseParser.Parse(rootElm);
}
}
}
diff --git a/OpenTween/Api/GraphQL/TimelineResponse.cs b/OpenTween/Api/GraphQL/TimelineResponse.cs
index 052ea6108..a2729df0c 100644
--- a/OpenTween/Api/GraphQL/TimelineResponse.cs
+++ b/OpenTween/Api/GraphQL/TimelineResponse.cs
@@ -34,7 +34,7 @@ public record TimelineResponse(
{
public TwitterStatus[] ToTwitterStatuses()
=> this.Tweets
- .Where(x => !x.IsTombstone)
+ .Where(x => x.IsAvailable)
.Select(x => x.ToTwitterStatus())
.ToArray();
}
diff --git a/OpenTween/Api/GraphQL/TimelineResponseParser.cs b/OpenTween/Api/GraphQL/TimelineResponseParser.cs
new file mode 100644
index 000000000..8fe315fdf
--- /dev/null
+++ b/OpenTween/Api/GraphQL/TimelineResponseParser.cs
@@ -0,0 +1,45 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System.Xml.Linq;
+using System.Xml.XPath;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class TimelineResponseParser
+ {
+ public static TimelineResponse Parse(XElement rootElm)
+ {
+ ErrorResponse.ThrowIfError(rootElm);
+
+ var tweets = TimelineTweet.ExtractTimelineTweets(rootElm);
+ if (tweets.Length == 0)
+ ErrorResponse.ThrowIfContainsRateLimitMessage(rootElm);
+
+ var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value;
+ var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value;
+
+ return new(tweets, cursorTop, cursorBottom);
+ }
+ }
+}
diff --git a/OpenTween/Api/GraphQL/TimelineTweet.cs b/OpenTween/Api/GraphQL/TimelineTweet.cs
index af7b1764d..e81403413 100644
--- a/OpenTween/Api/GraphQL/TimelineTweet.cs
+++ b/OpenTween/Api/GraphQL/TimelineTweet.cs
@@ -23,6 +23,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@@ -38,10 +39,10 @@ public class TimelineTweet
public XElement Element { get; }
- public bool IsTombstone
- => this.tombstoneElm != null;
+ public bool IsAvailable
+ => this.resultElm != null && !this.IsTombstoneResult(this.resultElm);
- private readonly XElement? tombstoneElm;
+ private readonly XElement? resultElm;
public TimelineTweet(XElement element)
{
@@ -50,19 +51,22 @@ public TimelineTweet(XElement element)
throw new ArgumentException($"Invalid itemType: {typeName}", nameof(element));
this.Element = element;
- this.tombstoneElm = this.TryGetTombstoneElm();
+ this.resultElm = this.TryGetResultElm();
}
- private XElement? TryGetTombstoneElm()
- => this.Element.XPathSelectElement("tweet_results/result[__typename[text()='TweetTombstone']]");
+ private XElement? TryGetResultElm()
+ => this.Element.XPathSelectElement("tweet_results/result");
+
+ private bool IsTombstoneResult([NotNullWhen(true)]XElement? resultElm)
+ => resultElm?.Element("__typename")?.Value == "TweetTombstone";
public TwitterStatus ToTwitterStatus()
{
- this.ThrowIfTweetIsTombstone();
+ this.ThrowIfTweetIsNotAvailable();
try
{
- var resultElm = this.Element.Element("tweet_results")?.Element("result") ?? throw CreateParseError();
+ var resultElm = this.resultElm ?? throw CreateParseError();
var status = TimelineTweet.ParseTweetUnion(resultElm);
if (this.Element.Element("promotedMetadata") != null)
@@ -78,12 +82,15 @@ public TwitterStatus ToTwitterStatus()
}
}
- public void ThrowIfTweetIsTombstone()
+ public void ThrowIfTweetIsNotAvailable()
{
- if (this.tombstoneElm == null)
+ if (this.IsAvailable)
return;
- var tombstoneText = this.tombstoneElm.XPathSelectElement("tombstone/text/text")?.Value;
+ string? tombstoneText = null;
+ if (this.IsTombstoneResult(this.resultElm))
+ tombstoneText = this.resultElm.XPathSelectElement("tombstone/text/text")?.Value;
+
var message = tombstoneText ?? "Tweet is not available";
var json = JsonUtils.JsonXmlToString(this.Element);
diff --git a/OpenTween/Api/GraphQL/UnfavoriteTweetRequest.cs b/OpenTween/Api/GraphQL/UnfavoriteTweetRequest.cs
new file mode 100644
index 000000000..c952da207
--- /dev/null
+++ b/OpenTween/Api/GraphQL/UnfavoriteTweetRequest.cs
@@ -0,0 +1,65 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Threading.Tasks;
+using OpenTween.Connection;
+using OpenTween.Models;
+
+namespace OpenTween.Api.GraphQL
+{
+ public class UnfavoriteTweetRequest
+ {
+ private static readonly Uri EndpointUri = new("https://twitter.com/i/api/graphql/ZYKSe-w7KEslx3JhSIk5LA/UnfavoriteTweet");
+
+ public required TwitterStatusId TweetId { get; set; }
+
+ public string CreateRequestBody()
+ {
+ return $$"""
+ {"variables":{"tweet_id":"{{JsonUtils.EscapeJsonString(this.TweetId.Id)}}"},"queryId":"ZYKSe-w7KEslx3JhSIk5LA"}
+ """;
+ }
+
+ public async Task Send(IApiConnection apiConnection)
+ {
+ var request = new PostJsonRequest
+ {
+ RequestUri = EndpointUri,
+ JsonString = this.CreateRequestBody(),
+ };
+
+ using var response = await apiConnection.SendAsync(request)
+ .ConfigureAwait(false);
+
+ var rootElm = await response.ReadAsJsonXml()
+ .ConfigureAwait(false);
+
+ ErrorResponse.ThrowIfError(rootElm);
+
+ return new();
+ }
+
+ public readonly record struct UnfavoriteTweetResponse();
+ }
+}
diff --git a/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs b/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs
index fff651342..c138868bb 100644
--- a/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs
+++ b/OpenTween/Api/GraphQL/UserTweetsAndRepliesRequest.cs
@@ -24,7 +24,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
-using System.Xml.XPath;
using OpenTween.Connection;
namespace OpenTween.Api.GraphQL
@@ -78,13 +77,7 @@ public async Task Send(IApiConnection apiConnection)
var rootElm = await response.ReadAsJsonXml()
.ConfigureAwait(false);
- ErrorResponse.ThrowIfError(rootElm);
-
- var tweets = TimelineTweet.ExtractTimelineTweets(rootElm);
- var cursorTop = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Top']]/value")?.Value;
- var cursorBottom = rootElm.XPathSelectElement("//content[__typename[text()='TimelineTimelineCursor']][cursorType[text()='Bottom']]/value")?.Value;
-
- return new(tweets, cursorTop, cursorBottom);
+ return TimelineResponseParser.Parse(rootElm);
}
}
}
diff --git a/OpenTween/Api/TwitterV2/NotificationsMentionsRequest.cs b/OpenTween/Api/TwitterV2/NotificationsMentionsRequest.cs
new file mode 100644
index 000000000..8f08a5ed8
--- /dev/null
+++ b/OpenTween/Api/TwitterV2/NotificationsMentionsRequest.cs
@@ -0,0 +1,187 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Runtime.Serialization;
+using System.Runtime.Serialization.Json;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using OpenTween.Api.DataModel;
+using OpenTween.Api.GraphQL;
+using OpenTween.Connection;
+
+namespace OpenTween.Api.TwitterV2
+{
+ public class NotificationsMentionsRequest
+ {
+ public static readonly string EndpointName = "/2/notifications/mentions";
+
+ private static readonly Uri EndpointUri = new("https://twitter.com/i/api/2/notifications/mentions.json");
+
+ public int Count { get; set; } = 100;
+
+ public string? Cursor { get; set; }
+
+ public Dictionary CreateParameters()
+ {
+ var param = new Dictionary()
+ {
+ ["include_profile_interstitial_type"] = "1",
+ ["include_blocking"] = "1",
+ ["include_blocked_by"] = "1",
+ ["include_followed_by"] = "1",
+ ["include_want_retweets"] = "1",
+ ["include_mute_edge"] = "1",
+ ["include_can_dm"] = "1",
+ ["include_can_media_tag"] = "1",
+ ["include_ext_has_nft_avatar"] = "1",
+ ["include_ext_is_blue_verified"] = "1",
+ ["include_ext_verified_type"] = "1",
+ ["include_ext_profile_image_shape"] = "1",
+ ["skip_status"] = "1",
+ ["cards_platform"] = "Web-12",
+ ["include_cards"] = "1",
+ ["include_ext_alt_text"] = "true",
+ ["include_ext_limited_action_results"] = "true",
+ ["include_quote_count"] = "true",
+ ["include_reply_count"] = "1",
+ ["tweet_mode"] = "extended",
+ ["include_ext_views"] = "true",
+ ["include_entities"] = "true",
+ ["include_user_entities"] = "true",
+ ["include_ext_media_color"] = "true",
+ ["include_ext_media_availability"] = "true",
+ ["include_ext_sensitive_media_warning"] = "true",
+ ["include_ext_trusted_friends_metadata"] = "true",
+ ["send_error_codes"] = "true",
+ ["simple_quoted_tweet"] = "true",
+ ["requestContext"] = "ptr",
+ ["ext"] = "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,superFollowMetadata,unmentionInfo,editControl",
+ ["count"] = this.Count.ToString(CultureInfo.InvariantCulture),
+ };
+
+ if (!MyCommon.IsNullOrEmpty(this.Cursor))
+ param["cursor"] = this.Cursor;
+
+ return param;
+ }
+
+ public async Task Send(IApiConnection apiConnection)
+ {
+ var request = new GetRequest
+ {
+ RequestUri = EndpointUri,
+ Query = this.CreateParameters(),
+ EndpointName = EndpointName,
+ };
+
+ using var response = await apiConnection.SendAsync(request)
+ .ConfigureAwait(false);
+
+ var responseBytes = await response.ReadAsBytes()
+ .ConfigureAwait(false);
+
+ ResponseRoot parsedObjects;
+ XElement rootElm;
+ try
+ {
+ parsedObjects = MyCommon.CreateDataFromJson(responseBytes);
+
+ using var jsonReader = JsonReaderWriterFactory.CreateJsonReader(
+ responseBytes,
+ XmlDictionaryReaderQuotas.Max
+ );
+
+ rootElm = XElement.Load(jsonReader);
+ }
+ catch (SerializationException ex)
+ {
+ var responseText = Encoding.UTF8.GetString(responseBytes);
+ throw TwitterApiException.CreateFromException(ex, responseText);
+ }
+ catch (XmlException ex)
+ {
+ var responseText = Encoding.UTF8.GetString(responseBytes);
+ throw new TwitterApiException("Invalid JSON", ex) { ResponseText = responseText };
+ }
+
+ ErrorResponse.ThrowIfError(rootElm);
+
+ var tweetIds = rootElm.XPathSelectElements("//content/item/content/tweet/id")
+ .Select(x => x.Value)
+ .ToArray();
+
+ var statuses = new List(tweetIds.Length);
+ foreach (var tweetId in tweetIds)
+ {
+ if (!parsedObjects.GlobalObjects.Tweets.TryGetValue(tweetId, out var tweet))
+ continue;
+
+ var userId = tweet.UserId;
+ if (!parsedObjects.GlobalObjects.Users.TryGetValue(userId, out var user))
+ continue;
+
+ tweet.User = user;
+ statuses.Add(tweet);
+ }
+
+ var cursorTop = rootElm.XPathSelectElement("//content/operation/cursor[cursorType[text()='Top']]/value")?.Value;
+ var cursorBottom = rootElm.XPathSelectElement("//content/operation/cursor[cursorType[text()='Bottom']]/value")?.Value;
+
+ return new(statuses.ToArray(), cursorTop, cursorBottom);
+ }
+
+ [DataContract]
+ private record ResponseRoot(
+ [property: DataMember(Name = "globalObjects")]
+ ResponseGlobalObjects GlobalObjects
+ );
+
+ [DataContract]
+ private record ResponseGlobalObjects(
+ [property: DataMember(Name = "users")]
+ Dictionary Users,
+ [property: DataMember(Name = "tweets")]
+ Dictionary Tweets
+ );
+
+ [DataContract]
+ private class ResponseTweet : TwitterStatus
+ {
+ [DataMember(Name = "user_id")]
+ public string UserId { get; set; } = "";
+ }
+
+ public readonly record struct NotificationsResponse(
+ TwitterStatus[] Statuses,
+ string? CursorTop,
+ string? CursorBottom
+ );
+ }
+}
diff --git a/OpenTween/AppendSettingDialog.cs b/OpenTween/AppendSettingDialog.cs
index 703cd51c0..0f8dfeda0 100644
--- a/OpenTween/AppendSettingDialog.cs
+++ b/OpenTween/AppendSettingDialog.cs
@@ -27,22 +27,15 @@
#nullable enable
using System;
-using System.Collections.Generic;
-using System.ComponentModel;
using System.Data;
-using System.Drawing;
-using System.IO;
using System.Linq;
-using System.Net.Http;
-using System.Resources;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenTween.Api;
using OpenTween.Connection;
+using OpenTween.Models;
using OpenTween.Setting.Panel;
-using OpenTween.Thumbnail;
namespace OpenTween
{
@@ -322,6 +315,7 @@ private void Setting_Shown(object sender, EventArgs e)
this.TopMost = this.PreviewPanel.CheckAlwaysTop.Checked;
this.GetPeriodPanel.LabelPostAndGet.Visible = this.GetPeriodPanel.CheckPostAndGet.Checked;
+ this.GetPeriodPanel.UpdateTabCounts(TabInformations.GetInstance());
}
private async Task OpenUrl(string url)
diff --git a/OpenTween/ApplicationEvents.cs b/OpenTween/ApplicationEvents.cs
index 9eb8b8b40..a7bf44a9c 100644
--- a/OpenTween/ApplicationEvents.cs
+++ b/OpenTween/ApplicationEvents.cs
@@ -73,6 +73,8 @@ public static int Main(string[] args)
ThemeManager.ApplyGlobalUIFont(settings.Local);
container.CultureService.Initialize();
+ container.TabInfo.LoadTabsFromSettings(settings.Tabs);
+ container.TabInfo.AddDefaultTabs();
Networking.Initialize();
settings.ApplySettings();
diff --git a/OpenTween/Models/FavoritesTabModel.cs b/OpenTween/Models/FavoritesTabModel.cs
index 861f1c5c4..4030b2a2a 100644
--- a/OpenTween/Models/FavoritesTabModel.cs
+++ b/OpenTween/Models/FavoritesTabModel.cs
@@ -43,6 +43,10 @@ public override MyCommon.TabUsageType TabType
public long OldestId { get; set; } = long.MaxValue;
+ public string? CursorTop { get; set; }
+
+ public string? CursorBottom { get; set; }
+
public FavoritesTabModel()
: this(MyCommon.DEFAULTTAB.FAV)
{
diff --git a/OpenTween/Models/MentionsTabModel.cs b/OpenTween/Models/MentionsTabModel.cs
index 1792b160f..a56675bd9 100644
--- a/OpenTween/Models/MentionsTabModel.cs
+++ b/OpenTween/Models/MentionsTabModel.cs
@@ -43,6 +43,10 @@ public override MyCommon.TabUsageType TabType
public PostId? OldestId { get; set; }
+ public string? CursorTop { get; set; }
+
+ public string? CursorBottom { get; set; }
+
public MentionsTabModel()
: this(MyCommon.DEFAULTTAB.REPLY)
{
diff --git a/OpenTween/Models/PublicSearchTabModel.cs b/OpenTween/Models/PublicSearchTabModel.cs
index 5c1c73f3e..51fab0dc1 100644
--- a/OpenTween/Models/PublicSearchTabModel.cs
+++ b/OpenTween/Models/PublicSearchTabModel.cs
@@ -99,12 +99,14 @@ await tw.GetSearch(read, this, backward)
}
///
- /// タブ更新時に使用する SinceId, OldestId をリセットする
+ /// 差分更新用の cursor をリセットする(検索条件が変更された時に使用する)
///
public void ResetFetchIds()
{
this.SinceId = null;
this.OldestId = null;
+ this.CursorTop = null;
+ this.CursorBottom = null;
}
}
}
diff --git a/OpenTween/Models/TabModel.cs b/OpenTween/Models/TabModel.cs
index 5989444e0..e5dc4e1d8 100644
--- a/OpenTween/Models/TabModel.cs
+++ b/OpenTween/Models/TabModel.cs
@@ -113,11 +113,15 @@ public PostClass? AnchorPost
set => this.AnchorStatusId = value?.StatusId;
}
+ public int UpdateCount
+ => this.updateCount;
+
private IndexedSortedSet ids = new();
private ConcurrentQueue addQueue = new();
private readonly ConcurrentQueue removeQueue = new();
private SortedSet unreadIds = new();
private List selectedStatusIds = new();
+ private int updateCount = 0;
private readonly object lockObj = new();
@@ -450,5 +454,8 @@ public IEnumerable SearchPostsAll(Func stringComparer, int st
}
}
}
+
+ public void IncrementUpdateCount()
+ => Interlocked.Increment(ref this.updateCount);
}
}
diff --git a/OpenTween/Properties/AssemblyInfo.cs b/OpenTween/Properties/AssemblyInfo.cs
index b9fdc6ac7..9bf93abc9 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.12.0.0")]
+[assembly: AssemblyVersion("3.13.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 32f7a5628..c03876d09 100644
--- a/OpenTween/Properties/Resources.Designer.cs
+++ b/OpenTween/Properties/Resources.Designer.cs
@@ -580,24 +580,18 @@ internal static string ChangeIconToolStripMenuItem_Confirm {
///
/// 更新履歴
///
- ///==== Ver 3.12.0(2024/01/20)
- /// * NEW: graphqlエンドポイントを使用したホームタイムラインの取得に対応
- ///
- ///==== Ver 3.11.0(2024/01/07)
- /// * NEW: Cookie使用時の関連発言表示に対応
- /// * FIX: APIリクエストのタイムアウト時に接続が切断されない場合がある不具合を修正
- /// * FIX: 存在しないユーザーのプロフィールを取得しようとした場合のエラーが適切に処理されない不具合を修正
- ///
- ///==== Ver 3.10.1(2023/12/23)
- /// * FIX: OAuth 1.0a によるAPIアクセスに失敗する不具合を修正
- ///
- ///==== Ver 3.10.0(2023/12/16)
- /// * NEW: graphqlエンドポイント経由で取得した引用ツイートの表示に対応
- /// * FIX: APIリクエストがタイムアウトした場合のキャンセル処理を改善
- ///
- ///==== Ver 3.9.0(2023/12/03)
- /// * NEW: graphqlエンドポイントに対するレートリミットの表示に対応
- /// * CHG: タ [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。
+ ///==== Ver 3.13.0(2024/01/27)
+ /// * NEW: Cookie使用時のReplyタブの更新に対応(/statuses/mentions_timeline.json 廃止に伴う対応)
+ /// * NEW: Cookie使用時のFavoritesタブの更新に対応
+ /// * NEW: Cookie使用時のFav追加・削除に対応
+ /// * NEW: ステータスバーに各タブの更新回数(起動時からの回数)の表示を追加
+ /// * NEW: 設定画面の更新間隔ページに24時間分の取得回数目安の表示を追加
+ /// * CHG: 更新間隔の初期設定を変更
+ /// * FIX: Cookie使用時にツイート検索の言語指定が効かない不具合を修正
+ /// * FIX: ツイート検索のキーワードを後から変更すると検索結果が表示されない不具合を修正
+ /// * FIX: Cookie使用時にステータスバーにRecentタブのレートリミットが表示されない不具合を修正
+ /// * FIX: 取得したツイートの中身が空だった場合のエラー処理を改善
+ /// * FIX: タイムラインの取得結果にレートリミットに関するメッセージが含まれていた [残りの文字列は切り詰められました]"; に類似しているローカライズされた文字列を検索します。
///
internal static string ChangeLog {
get {
@@ -1157,6 +1151,24 @@ internal static string GetFriendshipInfo8 {
}
}
+ ///
+ /// graphql エンドポイントのリクエスト回数目安: {0:#,0} 回 / 24 時間 に類似しているローカライズされた文字列を検索します。
+ ///
+ internal static string GetPeriodPanel_LabelGraphqlEstimate {
+ get {
+ return ResourceManager.GetString("GetPeriodPanel_LabelGraphqlEstimate", resourceCulture);
+ }
+ }
+
+ ///
+ /// (タブ数: {0}) に類似しているローカライズされた文字列を検索します。
+ ///
+ internal static string GetPeriodPanel_LabelTabCount {
+ get {
+ return ResourceManager.GetString("GetPeriodPanel_LabelTabCount", resourceCulture);
+ }
+ }
+
///
/// Recent更新完了 に類似しているローカライズされた文字列を検索します。
///
@@ -2570,6 +2582,15 @@ internal static string SetStatusLabelText3 {
}
}
+ ///
+ /// [更新: {0:#,0}] に類似しているローカライズされた文字列を検索します。
+ ///
+ internal static string SetStatusLabelText4 {
+ get {
+ return ResourceManager.GetString("SetStatusLabelText4", resourceCulture);
+ }
+ }
+
///
/// 「認証開始」ボタンを押すとブラウザが開きます。「連携アプリを認証」し、表示されたPINを画面上部に入力後、「Finish」ボタンを押してください。認証せずに終了してもよろしいですか? に類似しているローカライズされた文字列を検索します。
///
diff --git a/OpenTween/Properties/Resources.en.resx b/OpenTween/Properties/Resources.en.resx
index 824d29560..62d612824 100644
--- a/OpenTween/Properties/Resources.en.resx
+++ b/OpenTween/Properties/Resources.en.resx
@@ -117,6 +117,8 @@
Faild to get relation info.
Really Unfollow?
Confirm Unfollow
+ Estimated number of requests for graphql endpoint: {0:#,0} requests / 24 hours
+ (Tabs: {0})
Recent refreshed
Initial fetch completed
DMRcv refreshed
@@ -255,6 +257,7 @@ Available service: {1}
[Tab: {0}/{1} All: {2}/{3} (Reply: {4})] [Spd: Pst {5}/ Fav {6}/ TL {7}] [Interval:
-]
]
+ [Updates: {0:#,0}]
Press [Start Authentication] button and [Authorize App]. Key in PIN then press [Finish] button. Will you exit application without validating your account?
Failed to write settings to {0}.
diff --git a/OpenTween/Properties/Resources.resx b/OpenTween/Properties/Resources.resx
index bb846f4f5..f9cab82d6 100644
--- a/OpenTween/Properties/Resources.resx
+++ b/OpenTween/Properties/Resources.resx
@@ -124,6 +124,8 @@
フォロー状況取得失敗
フォロー解除しますか?
フォロー解除確認
+ graphql エンドポイントのリクエスト回数目安: {0:#,0} 回 / 24 時間
+ (タブ数: {0})
Recent更新完了
起動時読込完了
DMRcv更新完了
@@ -287,6 +289,7 @@
[タブ: {0}/{1} 全体: {2}/{3} (返信: {4})] [時速: 投 {5}/ ☆ {6}/ 流 {7}] [間隔:
-]
]
+ [更新: {0:#,0}]
「認証開始」ボタンを押すとブラウザが開きます。「連携アプリを認証」し、表示されたPINを画面上部に入力後、「Finish」ボタンを押してください。認証せずに終了してもよろしいですか?
{0} での設定の書き込みに失敗しました。
diff --git a/OpenTween/Setting/Panel/GetPeriodPanel.Designer.cs b/OpenTween/Setting/Panel/GetPeriodPanel.Designer.cs
index 41fd4ba04..222a7310d 100644
--- a/OpenTween/Setting/Panel/GetPeriodPanel.Designer.cs
+++ b/OpenTween/Setting/Panel/GetPeriodPanel.Designer.cs
@@ -43,6 +43,14 @@ private void InitializeComponent()
this.CheckPostAndGet = new System.Windows.Forms.CheckBox();
this.Label5 = new System.Windows.Forms.Label();
this.DMPeriod = new System.Windows.Forms.TextBox();
+ this.labelTabCountHome = new System.Windows.Forms.Label();
+ this.labelTabCountMentions = new System.Windows.Forms.Label();
+ this.labelTabCountDM = new System.Windows.Forms.Label();
+ this.labelTabCountSearch = new System.Windows.Forms.Label();
+ this.labelTabCountList = new System.Windows.Forms.Label();
+ this.labelTabCountUser = new System.Windows.Forms.Label();
+ this.labelGraphqlEstimate = new System.Windows.Forms.Label();
+ this.labelNoteForGraphqlLimits = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// Label21
@@ -54,6 +62,7 @@ private void InitializeComponent()
//
resources.ApplyResources(this.UserTimelinePeriod, "UserTimelinePeriod");
this.UserTimelinePeriod.Name = "UserTimelinePeriod";
+ this.UserTimelinePeriod.Validating += new System.ComponentModel.CancelEventHandler(this.UserTimeline_Validating);
//
// TimelinePeriod
//
@@ -121,6 +130,48 @@ private void InitializeComponent()
this.DMPeriod.Name = "DMPeriod";
this.DMPeriod.Validating += new System.ComponentModel.CancelEventHandler(this.DMPeriod_Validating);
//
+ // labelTabCountHome
+ //
+ resources.ApplyResources(this.labelTabCountHome, "labelTabCountHome");
+ this.labelTabCountHome.Name = "labelTabCountHome";
+ //
+ // labelTabCountMentions
+ //
+ resources.ApplyResources(this.labelTabCountMentions, "labelTabCountMentions");
+ this.labelTabCountMentions.Name = "labelTabCountMentions";
+ //
+ // labelTabCountDM
+ //
+ resources.ApplyResources(this.labelTabCountDM, "labelTabCountDM");
+ this.labelTabCountDM.Name = "labelTabCountDM";
+ //
+ // labelTabCountSearch
+ //
+ resources.ApplyResources(this.labelTabCountSearch, "labelTabCountSearch");
+ this.labelTabCountSearch.Name = "labelTabCountSearch";
+ //
+ // labelTabCountList
+ //
+ resources.ApplyResources(this.labelTabCountList, "labelTabCountList");
+ this.labelTabCountList.Name = "labelTabCountList";
+ //
+ // labelTabCountUser
+ //
+ resources.ApplyResources(this.labelTabCountUser, "labelTabCountUser");
+ this.labelTabCountUser.Name = "labelTabCountUser";
+ //
+ // labelGraphqlEstimate
+ //
+ resources.ApplyResources(this.labelGraphqlEstimate, "labelGraphqlEstimate");
+ this.labelGraphqlEstimate.Name = "labelGraphqlEstimate";
+ //
+ // labelNoteForGraphqlLimits
+ //
+ resources.ApplyResources(this.labelNoteForGraphqlLimits, "labelNoteForGraphqlLimits");
+ this.labelNoteForGraphqlLimits.BackColor = System.Drawing.SystemColors.ActiveCaption;
+ this.labelNoteForGraphqlLimits.ForeColor = System.Drawing.SystemColors.ActiveCaptionText;
+ this.labelNoteForGraphqlLimits.Name = "labelNoteForGraphqlLimits";
+ //
// GetPeriodPanel
//
resources.ApplyResources(this, "$this");
@@ -139,6 +190,14 @@ private void InitializeComponent()
this.Controls.Add(this.CheckPostAndGet);
this.Controls.Add(this.Label5);
this.Controls.Add(this.DMPeriod);
+ this.Controls.Add(this.labelNoteForGraphqlLimits);
+ this.Controls.Add(this.labelGraphqlEstimate);
+ this.Controls.Add(this.labelTabCountUser);
+ this.Controls.Add(this.labelTabCountList);
+ this.Controls.Add(this.labelTabCountSearch);
+ this.Controls.Add(this.labelTabCountDM);
+ this.Controls.Add(this.labelTabCountMentions);
+ this.Controls.Add(this.labelTabCountHome);
this.Name = "GetPeriodPanel";
this.ResumeLayout(false);
this.PerformLayout();
@@ -160,5 +219,13 @@ private void InitializeComponent()
internal System.Windows.Forms.CheckBox CheckPostAndGet;
internal System.Windows.Forms.Label Label5;
internal System.Windows.Forms.TextBox DMPeriod;
+ private System.Windows.Forms.Label labelTabCountHome;
+ private System.Windows.Forms.Label labelTabCountMentions;
+ private System.Windows.Forms.Label labelTabCountDM;
+ private System.Windows.Forms.Label labelTabCountSearch;
+ private System.Windows.Forms.Label labelTabCountList;
+ private System.Windows.Forms.Label labelTabCountUser;
+ private System.Windows.Forms.Label labelGraphqlEstimate;
+ private System.Windows.Forms.Label labelNoteForGraphqlLimits;
}
}
diff --git a/OpenTween/Setting/Panel/GetPeriodPanel.cs b/OpenTween/Setting/Panel/GetPeriodPanel.cs
index bf099b689..2be635eeb 100644
--- a/OpenTween/Setting/Panel/GetPeriodPanel.cs
+++ b/OpenTween/Setting/Panel/GetPeriodPanel.cs
@@ -27,13 +27,9 @@
#nullable enable
using System;
-using System.Collections.Generic;
using System.ComponentModel;
-using System.Data;
-using System.Drawing;
-using System.Linq;
-using System.Text;
using System.Windows.Forms;
+using OpenTween.Models;
namespace OpenTween.Setting.Panel
{
@@ -41,6 +37,8 @@ public partial class GetPeriodPanel : SettingPanelBase
{
public event EventHandler? IntervalChanged;
+ private TabTypeAggregation.Result tabCountByType;
+
public GetPeriodPanel()
=> this.InitializeComponent();
@@ -114,6 +112,38 @@ public void SaveConfig(SettingCommon settingCommon)
this.IntervalChanged?.Invoke(this, arg);
}
+ public void UpdateTabCounts(TabInformations tabInfo)
+ {
+ var tabCountByType = TabTypeAggregation.Aggregate(tabInfo);
+ this.tabCountByType = tabCountByType;
+
+ static string GetLabelText(int tabCount)
+ => string.Format(Properties.Resources.GetPeriodPanel_LabelTabCount, tabCount);
+
+ this.labelTabCountHome.Text = GetLabelText(tabCountByType.HomeTabs);
+ this.labelTabCountMentions.Text = GetLabelText(tabCountByType.MentionsTabs);
+ this.labelTabCountDM.Text = GetLabelText(tabCountByType.DMTabs);
+ this.labelTabCountSearch.Text = GetLabelText(tabCountByType.SearchTabs);
+ this.labelTabCountList.Text = GetLabelText(tabCountByType.ListTabs);
+ this.labelTabCountUser.Text = GetLabelText(tabCountByType.UserTabs);
+
+ this.EstimateGraphqlRequests();
+ }
+
+ private void EstimateGraphqlRequests()
+ {
+ var intervals = new GraphqlRequestEstimation.Intervals(
+ Home: int.Parse(this.TimelinePeriod.Text),
+ Search: int.Parse(this.PubSearchPeriod.Text),
+ List: int.Parse(this.ListsPeriod.Text),
+ User: int.Parse(this.UserTimelinePeriod.Text)
+ );
+ var estimate = GraphqlRequestEstimation.CalcDailyRequestCount(intervals, this.tabCountByType);
+
+ this.labelGraphqlEstimate.Text =
+ string.Format(Properties.Resources.GetPeriodPanel_LabelGraphqlEstimate, estimate);
+ }
+
private void TimelinePeriod_Validating(object sender, CancelEventArgs e)
{
if (!this.ValidateIntervalStr(this.TimelinePeriod.Text))
@@ -121,6 +151,8 @@ private void TimelinePeriod_Validating(object sender, CancelEventArgs e)
MessageBox.Show(Properties.Resources.TimelinePeriod_ValidatingText1);
e.Cancel = true;
}
+
+ this.EstimateGraphqlRequests();
}
private void ReplyPeriod_Validating(object sender, CancelEventArgs e)
@@ -148,6 +180,8 @@ private void PubSearchPeriod_Validating(object sender, CancelEventArgs e)
MessageBox.Show(Properties.Resources.TimelinePeriod_ValidatingText1);
e.Cancel = true;
}
+
+ this.EstimateGraphqlRequests();
}
private void ListsPeriod_Validating(object sender, CancelEventArgs e)
@@ -157,6 +191,8 @@ private void ListsPeriod_Validating(object sender, CancelEventArgs e)
MessageBox.Show(Properties.Resources.TimelinePeriod_ValidatingText1);
e.Cancel = true;
}
+
+ this.EstimateGraphqlRequests();
}
private void UserTimeline_Validating(object sender, CancelEventArgs e)
@@ -166,6 +202,8 @@ private void UserTimeline_Validating(object sender, CancelEventArgs e)
MessageBox.Show(Properties.Resources.TimelinePeriod_ValidatingText1);
e.Cancel = true;
}
+
+ this.EstimateGraphqlRequests();
}
private bool ValidateIntervalStr(string str)
diff --git a/OpenTween/Setting/Panel/GetPeriodPanel.en.resx b/OpenTween/Setting/Panel/GetPeriodPanel.en.resx
index 949f84f70..851206751 100644
--- a/OpenTween/Setting/Panel/GetPeriodPanel.en.resx
+++ b/OpenTween/Setting/Panel/GetPeriodPanel.en.resx
@@ -18,6 +18,22 @@
Reply Fetching Interval (sec.)
149, 12
Public Search Interval (sec.)
+ 383, 12
+ Estimated number of requests for graphql endpoint: 0 requests / 24 hours
+ 383, 12
+ When using cookies, the upper limit is 2,111 requests / 24 hours (approx.)
358, 12
Because "Post && fetch" is enabled, the API for each post consumed.
+ 50, 12
+ (Tabs: 0)
+ 50, 12
+ (Tabs: 0)
+ 50, 12
+ (Tabs: 0)
+ 50, 12
+ (Tabs: 0)
+ 50, 12
+ (Tabs: 0)
+ 50, 12
+ (Tabs: 0)
diff --git a/OpenTween/Setting/Panel/GetPeriodPanel.resx b/OpenTween/Setting/Panel/GetPeriodPanel.resx
index 83e1391f6..aac689004 100644
--- a/OpenTween/Setting/Panel/GetPeriodPanel.resx
+++ b/OpenTween/Setting/Panel/GetPeriodPanel.resx
@@ -43,10 +43,42 @@
$this
System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
7
+ labelGraphqlEstimate
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 15
+ labelNoteForGraphqlLimits
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 14
LabelPostAndGet
$this
System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
4
+ labelTabCountDM
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 19
+ labelTabCountHome
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 21
+ labelTabCountList
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 17
+ labelTabCountMentions
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 20
+ labelTabCountSearch
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 18
+ labelTabCountUser
+ $this
+ System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+ 16
ListsPeriod
$this
System.Windows.Forms.TextBox, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
@@ -71,72 +103,128 @@
NoControl
45, 38
84, 16
- 25
+ 3
投稿時取得
Disable
259, 92
65, 19
- 29
+ 8
True
NoControl
23, 182
144, 12
- 34
+ 16
UserTimeline更新間隔(秒)
True
NoControl
23, 20
130, 12
- 23
+ 0
タイムライン更新間隔(秒)
True
NoControl
23, 153
102, 12
- 32
+ 13
Lists更新間隔(秒)
True
NoControl
23, 95
94, 12
- 28
+ 7
DM更新間隔(秒)
True
NoControl
23, 66
123, 12
- 26
+ 4
Mentions更新間隔(秒)
True
NoControl
23, 124
137, 12
- 30
+ 10
Twitter検索更新間隔(秒)
+ True
+ NoControl
+ 23, 244
+ 3, 15, 3, 0
+ 290, 12
+ 20
+ graphql エンドポイントのリクエスト回数目安: 0 回 / 24 時間
+ True
+ NoControl
+ 23, 266
+ 3, 10, 3, 0
+ 311, 12
+ 21
+ Cookie 使用時は 2,111 回 / 24 時間 (推定) の制限があります
True
NoControl
- 23, 279
+ 23, 217
285, 12
- 36
+ 19
投稿時取得が有効のため、投稿のたびにAPIを消費します。
+ True
+ NoControl
+ 337, 95
+ 10, 0, 10, 0
+ 54, 12
+ 9
+ (タブ数: 0)
+ True
+ NoControl
+ 337, 20
+ 10, 0, 10, 0
+ 54, 12
+ 2
+ (タブ数: 0)
+ True
+ NoControl
+ 337, 153
+ 10, 0, 10, 0
+ 54, 12
+ 15
+ (タブ数: 0)
+ True
+ NoControl
+ 337, 66
+ 10, 0, 10, 0
+ 54, 12
+ 6
+ (タブ数: 0)
+ True
+ NoControl
+ 337, 124
+ 10, 0, 10, 0
+ 54, 12
+ 12
+ (タブ数: 0)
+ True
+ NoControl
+ 337, 182
+ 10, 0, 10, 0
+ 54, 12
+ 18
+ (タブ数: 0)
Disable
259, 150
65, 19
- 33
+ 14
Disable
259, 121
65, 19
- 31
+ 11
Disable
259, 63
65, 19
- 27
+ 5
Disable
259, 17
65, 19
- 24
+ 1
Disable
259, 179
65, 19
- 35
+ 17
diff --git a/OpenTween/Setting/Panel/GraphqlRequestEstimation.cs b/OpenTween/Setting/Panel/GraphqlRequestEstimation.cs
new file mode 100644
index 000000000..e023d6f27
--- /dev/null
+++ b/OpenTween/Setting/Panel/GraphqlRequestEstimation.cs
@@ -0,0 +1,50 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+namespace OpenTween.Setting.Panel
+{
+ public class GraphqlRequestEstimation
+ {
+ public static int CalcDailyRequestCount(Intervals intervals, TabTypeAggregation.Result tabCounts)
+ {
+ const int dayInSeconds = 1 * 24 * 60 * 60;
+
+ static int CalcDailyCount(int interval, int tabCount)
+ => interval == 0 ? 0 : dayInSeconds / interval * tabCount;
+
+ var homeDailyCount = CalcDailyCount(intervals.Home, tabCounts.HomeTabs);
+ var searchDailyCount = CalcDailyCount(intervals.Search, tabCounts.SearchTabs);
+ var listDailyCount = CalcDailyCount(intervals.List, tabCounts.ListTabs);
+ var userDaylyCount = CalcDailyCount(intervals.User, tabCounts.UserTabs);
+
+ return homeDailyCount + searchDailyCount + listDailyCount + userDaylyCount;
+ }
+
+ public readonly record struct Intervals(
+ int Home,
+ int Search,
+ int List,
+ int User
+ );
+ }
+}
diff --git a/OpenTween/Setting/Panel/TabTypeAggregation.cs b/OpenTween/Setting/Panel/TabTypeAggregation.cs
new file mode 100644
index 000000000..e338c8036
--- /dev/null
+++ b/OpenTween/Setting/Panel/TabTypeAggregation.cs
@@ -0,0 +1,58 @@
+// OpenTween - Client of Twitter
+// Copyright (c) 2024 kim_upsilon (@kim_upsilon)
+// All rights reserved.
+//
+// This file is part of OpenTween.
+//
+// This program is free software; you can redistribute it and/or modify it
+// under the terms of the GNU General Public License as published by the Free
+// Software Foundation; either version 3 of the License, or (at your option)
+// any later version.
+//
+// This program is distributed in the hope that it will be useful, but
+// WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+// for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program. If not, see , or write to
+// the Free Software Foundation, Inc., 51 Franklin Street - Fifth Floor,
+// Boston, MA 02110-1301, USA.
+
+#nullable enable
+
+using System.Linq;
+using OpenTween.Models;
+
+namespace OpenTween.Setting.Panel
+{
+ public class TabTypeAggregation
+ {
+ public static Result Aggregate(TabInformations tabInfo)
+ {
+ var countByTabTypes = tabInfo.Tabs.GroupBy(x => x.TabType)
+ .ToDictionary(x => x.Key, x => x.Count());
+
+ int GetCountByTabType(MyCommon.TabUsageType tabType)
+ => countByTabTypes.TryGetValue(tabType, out var count) ? count : 0;
+
+ return new(
+ HomeTabs: GetCountByTabType(MyCommon.TabUsageType.Home),
+ MentionsTabs: GetCountByTabType(MyCommon.TabUsageType.Mentions),
+ DMTabs: GetCountByTabType(MyCommon.TabUsageType.DirectMessage),
+ SearchTabs: GetCountByTabType(MyCommon.TabUsageType.PublicSearch),
+ ListTabs: GetCountByTabType(MyCommon.TabUsageType.Lists),
+ UserTabs: GetCountByTabType(MyCommon.TabUsageType.UserTimeline)
+ );
+ }
+
+ public readonly record struct Result(
+ int HomeTabs,
+ int MentionsTabs,
+ int DMTabs,
+ int SearchTabs,
+ int ListTabs,
+ int UserTabs
+ );
+ }
+}
diff --git a/OpenTween/Setting/SettingCommon.cs b/OpenTween/Setting/SettingCommon.cs
index c57f1ee2d..dbc4a942a 100644
--- a/OpenTween/Setting/SettingCommon.cs
+++ b/OpenTween/Setting/SettingCommon.cs
@@ -114,11 +114,11 @@ private string Decrypt(string password)
public long UserId = 0;
public List TabList = new();
- public int TimelinePeriod = 90;
+ public int TimelinePeriod = 180;
public int ReplyPeriod = 180;
public int DMPeriod = 600;
- public int PubSearchPeriod = 180;
- public int ListsPeriod = 180;
+ public int PubSearchPeriod = 360;
+ public int ListsPeriod = 360;
///
/// 起動時読み込み分を既読にするか。trueなら既読として処理
@@ -230,7 +230,7 @@ private string Decrypt(string password)
public int SearchCountApi = 100;
public int FavoritesCountApi = 40;
public int UserTimelineCountApi = 20;
- public int UserTimelinePeriod = 600;
+ public int UserTimelinePeriod = 360;
public bool OpenUserTimeline = true;
public int ListCountApi = 100;
public int UseImageService = 0;
diff --git a/OpenTween/Tween.cs b/OpenTween/Tween.cs
index ea324c85f..e0e5a14f8 100644
--- a/OpenTween/Tween.cs
+++ b/OpenTween/Tween.cs
@@ -301,10 +301,6 @@ ThumbnailGenerator thumbGenerator
this.NotifyIcon1.Icon = this.iconAssets.IconTray; // タスクトレイ
this.TabImage.Images.Add(this.iconAssets.IconTab); // タブ見出し
- // <<<<<<<<<設定関連>>>>>>>>>
- // 設定読み出し
- this.LoadConfig();
-
// 現在の DPI と設定保存時の DPI との比を取得する
var configScaleFactor = this.settings.Local.GetConfigScaleFactor(this.CurrentAutoScaleDimensions);
@@ -765,12 +761,6 @@ private void ListTab_DrawItem(object sender, DrawItemEventArgs e)
e.Graphics.DrawString(txt, e.Font, fore, e.Bounds, this.sfTab);
}
- private void LoadConfig()
- {
- this.statuses.LoadTabsFromSettings(this.settings.Tabs);
- this.statuses.AddDefaultTabs();
- }
-
private void TimerInterval_Changed(object sender, IntervalChangedEventArgs e)
{
this.RefreshTimelineScheduler();
@@ -1313,6 +1303,7 @@ private async Task RefreshTabAsync(TabModel tab, bool backward)
{
this.RefreshTasktrayIcon();
await Task.Run(() => tab.RefreshAsync(this.tw, backward, this.initial, this.workerProgress));
+ tab.IncrementUpdateCount();
}
catch (WebApiException ex)
{
@@ -1381,17 +1372,9 @@ await Task.Run(async () =>
try
{
var twitterStatusId = (post.RetweetedId ?? post.StatusId).ToTwitterStatusId();
- try
- {
- await this.tw.Api.FavoritesCreate(twitterStatusId)
- .IgnoreResponse()
- .ConfigureAwait(false);
- }
- catch (TwitterApiException ex)
- when (ex.Errors.All(x => x.Code == TwitterErrorCode.AlreadyFavorited))
- {
- // エラーコード 139 のみの場合は成功と見なす
- }
+
+ await this.tw.PostFavAdd(twitterStatusId)
+ .ConfigureAwait(false);
if (this.settings.Common.RestrictFavCheck)
{
@@ -1511,9 +1494,7 @@ await Task.Run(async () =>
try
{
- await this.tw.Api.FavoritesDestroy(twitterStatusId)
- .IgnoreResponse()
- .ConfigureAwait(false);
+ await this.tw.PostFavRemove(twitterStatusId);
}
catch (WebApiException)
{
@@ -6958,6 +6939,8 @@ private string GetStatusLabelText()
{
slbl.Append(this.settings.Common.TimelinePeriod + Properties.Resources.SetStatusLabelText3);
}
+ slbl.Append(" ");
+ slbl.AppendFormat(Properties.Resources.SetStatusLabelText4, this.CurrentTab.UpdateCount);
return slbl.ToString();
}
@@ -6996,10 +6979,14 @@ private void SetApiStatusLabel(string? endpointName = null)
// 表示中のタブに応じて更新
endpointName = tabType switch
{
- MyCommon.TabUsageType.Home => "/statuses/home_timeline",
- MyCommon.TabUsageType.UserDefined => "/statuses/home_timeline",
- MyCommon.TabUsageType.Mentions => "/statuses/mentions_timeline",
- MyCommon.TabUsageType.Favorites => "/favorites/list",
+ MyCommon.TabUsageType.Home =>
+ authByCookie ? HomeLatestTimelineRequest.EndpointName : "/statuses/home_timeline",
+ MyCommon.TabUsageType.UserDefined =>
+ authByCookie ? HomeLatestTimelineRequest.EndpointName : "/statuses/home_timeline",
+ MyCommon.TabUsageType.Mentions =>
+ authByCookie ? NotificationsMentionsRequest.EndpointName : "/statuses/mentions_timeline",
+ MyCommon.TabUsageType.Favorites =>
+ authByCookie ? LikesRequest.EndpointName : "/favorites/list",
MyCommon.TabUsageType.DirectMessage => "/direct_messages/events/list",
MyCommon.TabUsageType.UserTimeline =>
authByCookie ? UserTweetsAndRepliesRequest.EndpointName : "/statuses/user_timeline",
@@ -7007,7 +6994,8 @@ private void SetApiStatusLabel(string? endpointName = null)
authByCookie ? ListLatestTweetsTimelineRequest.EndpointName : "/lists/statuses",
MyCommon.TabUsageType.PublicSearch =>
authByCookie ? SearchTimelineRequest.EndpointName : "/search/tweets",
- MyCommon.TabUsageType.Related => "/statuses/show/:id",
+ MyCommon.TabUsageType.Related =>
+ authByCookie ? TweetDetailRequest.EndpointName : "/statuses/show/:id",
_ => null,
};
this.toolStripApiGauge.ApiEndpoint = endpointName;
diff --git a/OpenTween/Twitter.cs b/OpenTween/Twitter.cs
index 4e1030f55..aaa8b7c04 100644
--- a/OpenTween/Twitter.cs
+++ b/OpenTween/Twitter.cs
@@ -30,6 +30,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@@ -474,6 +475,54 @@ public async Task GetUserInfo(string screenName)
}
}
+ public async Task PostFavAdd(TwitterStatusId statusId)
+ {
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new FavoriteTweetRequest
+ {
+ TweetId = statusId,
+ };
+
+ await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ try
+ {
+ await this.Api.FavoritesCreate(statusId)
+ .IgnoreResponse()
+ .ConfigureAwait(false);
+ }
+ catch (TwitterApiException ex)
+ when (ex.Errors.All(x => x.Code == TwitterErrorCode.AlreadyFavorited))
+ {
+ // エラーコード 139 のみの場合は成功と見なす
+ }
+ }
+ }
+
+ public async Task PostFavRemove(TwitterStatusId statusId)
+ {
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
+ {
+ var request = new UnfavoriteTweetRequest
+ {
+ TweetId = statusId,
+ };
+
+ await request.Send(this.Api.Connection)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ await this.Api.FavoritesDestroy(statusId)
+ .IgnoreResponse()
+ .ConfigureAwait(false);
+ }
+ }
+
public string Username
=> this.Api.CurrentScreenName;
@@ -651,15 +700,35 @@ public async Task GetMentionsTimelineApi(bool read, MentionsTabModel tab, bool m
var count = GetApiResultCount(MyCommon.WORKERTYPE.Reply, more, startup);
TwitterStatus[] statuses;
- if (more)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
+ var request = new NotificationsMentionsRequest
+ {
+ Count = Math.Min(count, 50),
+ Cursor = more ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
.ConfigureAwait(false);
+
+ statuses = response.Statuses;
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!more)
+ tab.CursorTop = response.CursorTop;
}
else
{
- statuses = await this.Api.StatusesMentionsTimeline(count)
- .ConfigureAwait(false);
+ if (more)
+ {
+ statuses = await this.Api.StatusesMentionsTimeline(count, maxId: tab.OldestId as TwitterStatusId)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ statuses = await this.Api.StatusesMentionsTimeline(count)
+ .ConfigureAwait(false);
+ }
}
var minimumId = this.CreatePostsFromJson(statuses, MyCommon.WORKERTYPE.Reply, tab, read);
@@ -1113,7 +1182,12 @@ public async Task GetSearch(bool read, PublicSearchTabModel tab, bool more)
TwitterStatus[] statuses;
if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- var request = new SearchTimelineRequest(tab.SearchWords)
+ var query = tab.SearchWords;
+
+ if (!MyCommon.IsNullOrEmpty(tab.SearchLang))
+ query = $"({query}) lang:{tab.SearchLang}";
+
+ var request = new SearchTimelineRequest(query)
{
Count = count,
Cursor = more ? tab.CursorBottom : tab.CursorTop,
@@ -1243,15 +1317,36 @@ public async Task GetFavoritesApi(bool read, FavoritesTabModel tab, bool backwar
var count = GetApiResultCount(MyCommon.WORKERTYPE.Favorites, backward, false);
TwitterStatus[] statuses;
- if (backward)
+ if (this.Api.AuthType == APIAuthType.TwitterComCookie)
{
- statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
+ var request = new LikesRequest
+ {
+ UserId = this.UserId.ToString(CultureInfo.InvariantCulture),
+ Count = count,
+ Cursor = backward ? tab.CursorBottom : tab.CursorTop,
+ };
+ var response = await request.Send(this.Api.Connection)
.ConfigureAwait(false);
+
+ statuses = response.ToTwitterStatuses();
+
+ tab.CursorBottom = response.CursorBottom;
+
+ if (!backward)
+ tab.CursorTop = response.CursorTop;
}
else
{
- statuses = await this.Api.FavoritesList(count)
- .ConfigureAwait(false);
+ if (backward)
+ {
+ statuses = await this.Api.FavoritesList(count, maxId: tab.OldestId)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ statuses = await this.Api.FavoritesList(count)
+ .ConfigureAwait(false);
+ }
}
var minimumId = this.CreateFavoritePostsFromJson(statuses, read);
diff --git a/appveyor.yml b/appveyor.yml
index 001f7c2fe..1a1f8122e 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,4 +1,4 @@
-version: 3.11.0.{build}
+version: 3.12.0.{build}
os: Visual Studio 2022