From e371fa0e248f96b083c37e139827b0520ca48c8f Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 12 Feb 2019 19:22:37 +0100 Subject: [PATCH] Tab customization & direct messages tab (#1012) * custom tabs * custom tabs interface * implement custom tab functionality * add database migration * fix bugs, improve ThemeUtils nullability handling * implement conversationsfragment * setup ConversationViewHolder * implement favs * add button functionality * revert 10.json * revert item_status_notification.xml * implement more menu, replying, fix stuff, clean up * fix tests * fix bug with expanding statuses * min and max number of tabs * settings support, fix bugs * database migration * fix scrolling to top after refresh * fix bugs * fix warning in item_conversation --- app/build.gradle | 1 + .../12.json | 668 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../tusky/BottomSheetActivity.kt | 8 +- .../com/keylesspalace/tusky/ListsActivity.kt | 2 - .../com/keylesspalace/tusky/MainActivity.java | 95 ++- .../tusky/SavedTootActivity.java | 2 +- .../java/com/keylesspalace/tusky/TabData.kt | 57 ++ .../tusky/TabPreferenceActivity.kt | 205 ++++++ .../keylesspalace/tusky/TuskyApplication.java | 3 +- .../tusky/adapter/NetworkStateViewHolder.kt | 45 ++ .../tusky/adapter/PlaceholderViewHolder.java | 2 +- .../tusky/adapter/StatusBaseViewHolder.java | 103 +-- .../adapter/StatusDetailedViewHolder.java | 4 +- .../tusky/adapter/StatusViewHolder.java | 6 +- .../keylesspalace/tusky/adapter/TabAdapter.kt | 80 +++ .../tusky/appstore/CacheUpdater.kt | 2 +- .../keylesspalace/tusky/appstore/Events.kt | 4 +- .../conversation/ConversationAdapter.kt | 108 +++ .../conversation/ConversationEntity.kt | 186 +++++ .../conversation/ConversationViewHolder.java | 157 ++++ .../ConversationsBoundaryCallback.kt | 98 +++ .../conversation/ConversationsFragment.kt | 189 +++++ .../conversation/ConversationsRepository.kt | 101 +++ .../conversation/ConversationsViewModel.kt | 104 +++ .../keylesspalace/tusky/db/AccountEntity.kt | 5 +- .../keylesspalace/tusky/db/AppDatabase.java | 47 +- .../tusky/db/ConversationsDao.kt | 40 ++ .../com/keylesspalace/tusky/db/Converters.kt | 96 ++- .../tusky/di/ActivitiesModule.kt | 3 + .../tusky/di/FragmentBuildersModule.kt | 4 + .../keylesspalace/tusky/di/NetworkModule.kt | 1 + .../tusky/di/ViewModelFactory.kt | 6 + .../com/keylesspalace/tusky/entity/Account.kt | 14 +- .../tusky/entity/Conversation.kt | 25 + .../com/keylesspalace/tusky/entity/Status.kt | 2 +- .../tusky/fragment/AccountListFragment.kt | 6 +- .../tusky/fragment/AccountMediaFragment.kt | 34 +- .../tusky/fragment/NotificationsFragment.java | 10 +- .../tusky/fragment/SFragment.java | 2 +- .../tusky/fragment/SearchFragment.kt | 4 +- .../tusky/fragment/TimelineFragment.java | 10 +- .../tusky/fragment/ViewThreadFragment.java | 8 +- .../preference/AccountPreferencesFragment.kt | 18 +- .../preference/PreferencesFragment.kt | 6 +- .../interfaces/StatusActionListener.java | 6 +- .../tusky/json/SpannedTypeAdapter.java | 10 +- .../tusky/network/MastodonApi.java | 4 + .../tusky/pager/MainPagerAdapter.kt | 46 ++ .../tusky/pager/TimelinePagerAdapter.java | 60 -- .../com/keylesspalace/tusky/util/Listing.kt | 36 + .../keylesspalace/tusky/util/NetworkState.kt | 34 + .../tusky/util/PagingRequestHelper.java | 491 +++++++++++++ .../keylesspalace/tusky/util/ThemeUtils.java | 33 +- .../tusky/util/getErrorMessage.kt | 23 + app/src/main/res/color/tab_icon_color.xml | 5 + app/src/main/res/drawable/avatar_border.xml | 6 + .../res/drawable/ic_drag_indicator_24dp.xml | 9 + app/src/main/res/drawable/ic_plus_24dp.xml | 9 + .../res/layout-sw640dp/fragment_timeline.xml | 7 +- .../layout-sw640dp/fragment_view_thread.xml | 4 +- app/src/main/res/layout/activity_main.xml | 27 +- .../main/res/layout/activity_saved_toot.xml | 2 +- .../res/layout/activity_tab_preference.xml | 80 +++ app/src/main/res/layout/fragment_timeline.xml | 6 +- .../main/res/layout/fragment_view_thread.xml | 4 +- app/src/main/res/layout/item_conversation.xml | 391 ++++++++++ .../main/res/layout/item_network_state.xml | 23 + .../res/layout/item_status_placeholder.xml | 2 +- .../main/res/layout/item_tab_preference.xml | 28 + .../res/layout/item_tab_preference_small.xml | 17 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 8 + app/src/main/res/xml/account_preferences.xml | 4 + .../tusky/BottomSheetActivityTest.kt | 10 +- 75 files changed, 3663 insertions(+), 296 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/TabData.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/pager/TimelinePagerAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Listing.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt create mode 100644 app/src/main/res/color/tab_icon_color.xml create mode 100644 app/src/main/res/drawable/avatar_border.xml create mode 100644 app/src/main/res/drawable/ic_drag_indicator_24dp.xml create mode 100644 app/src/main/res/drawable/ic_plus_24dp.xml create mode 100644 app/src/main/res/layout/activity_tab_preference.xml create mode 100644 app/src/main/res/layout/item_conversation.xml create mode 100644 app/src/main/res/layout/item_network_state.xml create mode 100644 app/src/main/res/layout/item_tab_preference.xml create mode 100644 app/src/main/res/layout/item_tab_preference_small.xml diff --git a/app/build.gradle b/app/build.gradle index 169db7fd4..252626ebc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,4 +137,5 @@ dependencies { implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0' implementation 'com.uber.autodispose:autodispose-android-archcomponents:1.1.0' implementation 'com.uber.autodispose:autodispose-ktx:1.1.0' + implementation 'androidx.paging:paging-runtime-ktx:2.1.0-rc01' } diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json new file mode 100644 index 000000000..c2175907e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json @@ -0,0 +1,668 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "d4d3d4c683ab7f681459b9edab92301c", + "entities": [ + { + "tableName": "TootEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urls", + "columnName": "urls", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "descriptions", + "columnName": "descriptions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToText", + "columnName": "inReplyToText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToUsername", + "columnName": "inReplyToUsername", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeNotifications", + "columnName": "activeNotifications", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsible", + "columnName": "s_collapsible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"d4d3d4c683ab7f681459b9edab92301c\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 684194159..40397a94b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,6 +97,7 @@ + diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 0bb02371a..5e1b4c7f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -85,7 +85,7 @@ abstract class BottomSheetActivity : BaseActivity() { val searchResult = response.body() if(searchResult != null) { if (searchResult.statuses.isNotEmpty()) { - viewThread(searchResult.statuses[0]) + viewThread(searchResult.statuses[0].id, searchResult.statuses[0].url) return } else if (searchResult.accounts.isNotEmpty()) { viewAccount(searchResult.accounts[0].id) @@ -107,11 +107,11 @@ abstract class BottomSheetActivity : BaseActivity() { onBeginSearch(url) } - open fun viewThread(status: Status) { + open fun viewThread(statusId: String, url: String?) { if (!isSearching()) { val intent = Intent(this, ViewThreadActivity::class.java) - intent.putExtra("id", status.actionableId) - intent.putExtra("url", status.actionableStatus.url) + intent.putExtra("id", statusId) + intent.putExtra("url", url) startActivityWithSlideInAnimation(intent) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index 1196bb8e1..e95b7df88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -8,12 +8,10 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.annotation.StringRes import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.LoadingState.* import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.MastoList diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index e2110c8cd..87bcf017a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -38,11 +38,12 @@ import android.widget.ImageView; import com.keylesspalace.tusky.appstore.CacheUpdater; import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.MainTabsChangedEvent; import com.keylesspalace.tusky.appstore.ProfileEditedEvent; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.interfaces.ActionButtonActivity; -import com.keylesspalace.tusky.pager.TimelinePagerAdapter; +import com.keylesspalace.tusky.pager.MainPagerAdapter; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.NotificationHelper; import com.keylesspalace.tusky.util.ThemeUtils; @@ -106,22 +107,17 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut private FloatingActionButton composeButton; private AccountHeader headerResult; private Drawer drawer; + private TabLayout tabLayout; private ViewPager viewPager; - private void forwardShare(Intent intent) { - Intent composeIntent = new Intent(this, ComposeActivity.class); - composeIntent.setAction(intent.getAction()); - composeIntent.setType(intent.getType()); - composeIntent.putExtras(intent); - startActivity(composeIntent); - } + private int notificationTabPosition; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Intent intent = getIntent(); - int tabPosition = 0; + boolean showNotificationTab = false; if (intent != null) { long accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1); @@ -156,14 +152,14 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut } } else if (accountRequested) { // user clicked a notification, show notification tab and switch user if necessary - tabPosition = 1; + showNotificationTab = true; } } setContentView(R.layout.activity_main); composeButton = findViewById(R.id.floating_btn); ImageButton drawerToggle = findViewById(R.id.drawer_toggle); - TabLayout tabLayout = findViewById(R.id.tab_layout); + tabLayout = findViewById(R.id.tab_layout); viewPager = findViewById(R.id.pager); composeButton.setOnClickListener(v -> { @@ -181,60 +177,26 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut * drawer, though, because its callback touches the header in the drawer. */ fetchUserInfo(); - // Setup the tabs and timeline pager. - TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager()); + setupTabs(showNotificationTab); int pageMargin = getResources().getDimensionPixelSize(R.dimen.tab_page_margin); viewPager.setPageMargin(pageMargin); Drawable pageMarginDrawable = ThemeUtils.getDrawable(this, R.attr.tab_page_margin_drawable, R.drawable.tab_page_margin_dark); viewPager.setPageMarginDrawable(pageMarginDrawable); - viewPager.setAdapter(adapter); - - tabLayout.setupWithViewPager(viewPager); - - int[] tabIcons = { - R.drawable.ic_home_24dp, - R.drawable.ic_notifications_24dp, - R.drawable.ic_local_24dp, - R.drawable.ic_public_24dp, - }; - String[] pageTitles = { - getString(R.string.title_home), - getString(R.string.title_notifications), - getString(R.string.title_public_local), - getString(R.string.title_public_federated), - }; - for (int i = 0; i < 4; i++) { - TabLayout.Tab tab = tabLayout.getTabAt(i); - tab.setIcon(tabIcons[i]); - tab.setContentDescription(pageTitles[i]); - } - - if (tabPosition != 0) { - TabLayout.Tab tab = tabLayout.getTabAt(tabPosition); - if (tab != null) { - tab.select(); - } else { - tabPosition = 0; - } - } tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) { viewPager.setCurrentItem(tab.getPosition()); - tintTab(tab, true); - - if (tab.getPosition() == 1) { + if (tab.getPosition() == notificationTabPosition) { NotificationHelper.clearNotificationsForActiveAccount(MainActivity.this, accountManager); } } @Override public void onTabUnselected(TabLayout.Tab tab) { - tintTab(tab, false); } @Override @@ -242,10 +204,6 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut } }); - for (int i = 0; i < 4; i++) { - tintTab(tabLayout.getTabAt(i), i == tabPosition); - } - // Setup push notifications if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { NotificationHelper.enablePullNotifications(); @@ -260,6 +218,9 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut if (event instanceof ProfileEditedEvent) { onFetchUserInfoSuccess(((ProfileEditedEvent) event).getNewProfileData()); } + if (event instanceof MainTabsChangedEvent) { + setupTabs(false); + } }); // Flush old media that was cached for sharing @@ -316,9 +277,12 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut } } - private void tintTab(TabLayout.Tab tab, boolean tinted) { - int color = (tinted) ? R.attr.tab_icon_selected_tint : R.attr.toolbar_icon_tint; - ThemeUtils.setDrawableTint(this, tab.getIcon(), color); + private void forwardShare(Intent intent) { + Intent composeIntent = new Intent(this, ComposeActivity.class); + composeIntent.setAction(intent.getAction()); + composeIntent.setType(intent.getType()); + composeIntent.putExtras(intent); + startActivity(composeIntent); } private void setupDrawer() { @@ -433,6 +397,29 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut }); } + private void setupTabs(boolean selectNotificationTab) { + List tabs = accountManager.getActiveAccount().getTabPreferences(); + + MainPagerAdapter adapter = new MainPagerAdapter(tabs, getSupportFragmentManager()); + viewPager.setAdapter(adapter); + + tabLayout.setupWithViewPager(viewPager); + tabLayout.removeAllTabs(); + for (int i = 0; i < tabs.size(); i++) { + TabLayout.Tab tab = tabLayout.newTab() + .setIcon(tabs.get(i).getIcon()) + .setContentDescription(tabs.get(i).getText()); + tabLayout.addTab(tab); + if(tabs.get(i).getId().equals(TabDataKt.NOTIFICATIONS)) { + notificationTabPosition = i; + if(selectNotificationTab) { + tab.select(); + } + } + } + + } + private boolean handleProfileClick(IProfile profile, boolean current) { AccountEntity activeAccount = accountManager.getActiveAccount(); diff --git a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java index e5d268692..1cd144166 100644 --- a/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/SavedTootActivity.java @@ -92,7 +92,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd bar.setDisplayShowHomeEnabled(true); } - RecyclerView recyclerView = findViewById(R.id.recycler_view); + RecyclerView recyclerView = findViewById(R.id.recyclerView); noContent = findViewById(R.id.no_content); recyclerView.setHasFixedSize(true); LinearLayoutManager layoutManager = new LinearLayoutManager(this); diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt new file mode 100644 index 000000000..20cd76c07 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -0,0 +1,57 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import com.keylesspalace.tusky.components.conversation.ConversationsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.fragment.TimelineFragment + +/** this would be a good case for a sealed class, but that does not work nice with Room */ + +const val HOME = "Home" +const val NOTIFICATIONS = "Notifications" +const val LOCAL = "Local" +const val FEDERATED = "Federated" +const val DIRECT = "Direct" + +data class TabData(val id: String, + @StringRes val text: Int, + @DrawableRes val icon: Int, + val fragment: () -> Fragment) + + +fun createTabDataFromId(id: String): TabData { + return when (id) { + HOME -> TabData(HOME, R.string.title_home, R.drawable.ic_home_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) } + NOTIFICATIONS -> TabData(NOTIFICATIONS, R.string.title_notifications, R.drawable.ic_notifications_24dp) { NotificationsFragment.newInstance() } + LOCAL -> TabData(LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) } + FEDERATED -> TabData(FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) } + DIRECT -> TabData(DIRECT, R.string.title_direct_messages, R.drawable.reblog_direct_dark) { ConversationsFragment.newInstance() } + else -> throw IllegalArgumentException("unknown tab type") + } +} + +fun defaultTabs(): List { + return listOf( + createTabDataFromId(HOME), + createTabDataFromId(NOTIFICATIONS), + createTabDataFromId(LOCAL), + createTabDataFromId(FEDERATED) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt new file mode 100644 index 000000000..ffdbf8b70 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -0,0 +1,205 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky + +import android.os.Bundle +import android.view.MenuItem +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.adapter.ItemInteractionListener +import com.keylesspalace.tusky.adapter.TabAdapter +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.util.visible +import kotlinx.android.synthetic.main.activity_tab_preference.* +import kotlinx.android.synthetic.main.toolbar_basic.* +import javax.inject.Inject + +class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener { + + @Inject + lateinit var eventHub: EventHub + + private lateinit var currentTabs: MutableList + private lateinit var currentTabsAdapter: TabAdapter + private lateinit var touchHelper: ItemTouchHelper + private lateinit var addTabAdapter: TabAdapter + + private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_tab_preference) + + setSupportActionBar(toolbar) + + supportActionBar?.apply { + setTitle(R.string.title_tab_preferences) + setDisplayHomeAsUpEnabled(true) + setDisplayShowHomeEnabled(true) + } + + currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList() + currentTabsAdapter = TabAdapter(currentTabs, false, this) + currentTabsRecyclerView.adapter = currentTabsAdapter + currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) + currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) + + addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this) + addTabRecyclerView.adapter = addTabAdapter + addTabRecyclerView.layoutManager = LinearLayoutManager(this) + + touchHelper = ItemTouchHelper(object: ItemTouchHelper.Callback(){ + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) + } + + override fun isLongPressDragEnabled(): Boolean { + return true + } + + override fun isItemViewSwipeEnabled(): Boolean { + return MIN_TAB_COUNT < currentTabs.size + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + val temp = currentTabs[viewHolder.adapterPosition] + currentTabs[viewHolder.adapterPosition] = currentTabs[target.adapterPosition] + currentTabs[target.adapterPosition] = temp + + currentTabsAdapter.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition) + saveTabs() + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + currentTabs.removeAt(viewHolder.adapterPosition) + currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition) + updateAvailableTabs() + saveTabs() + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + if(actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.elevation = selectedItemElevation + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.elevation = 0f + } + }) + + touchHelper.attachToRecyclerView(currentTabsRecyclerView) + + + actionButton.setOnClickListener { + actionButton.isExpanded = true + } + + scrim.setOnClickListener { + actionButton.isExpanded = false + } + + updateAvailableTabs() + + } + + override fun onTabAdded(tab: TabData) { + currentTabs.add(tab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + actionButton.isExpanded = false + updateAvailableTabs() + saveTabs() + } + + private fun updateAvailableTabs() { + val addableTabs: MutableList = mutableListOf() + + val homeTab = createTabDataFromId(HOME) + if(!currentTabs.contains(homeTab)) { + addableTabs.add(homeTab) + } + val notificationTab = createTabDataFromId(NOTIFICATIONS) + if(!currentTabs.contains(notificationTab)) { + addableTabs.add(notificationTab) + } + val localTab = createTabDataFromId(LOCAL) + if(!currentTabs.contains(localTab)) { + addableTabs.add(localTab) + } + val federatedTab = createTabDataFromId(FEDERATED) + if(!currentTabs.contains(federatedTab)) { + addableTabs.add(federatedTab) + } + val directMessagesTab = createTabDataFromId(DIRECT) + if(!currentTabs.contains(directMessagesTab)) { + addableTabs.add(directMessagesTab) + } + + addTabAdapter.updateData(addableTabs) + + maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) + + } + + override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { + touchHelper.startSwipe(viewHolder) + } + + override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { + touchHelper.startDrag(viewHolder) + } + + private fun saveTabs() { + accountManager.activeAccount?.let { + it.tabPreferences = currentTabs + accountManager.saveAccount(it) + } + } + + override fun onBackPressed() { + if (actionButton.isExpanded) { + actionButton.isExpanded = false + } else { + super.onBackPressed() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + onBackPressed() + return true + } + return false + } + + override fun onPause() { + super.onPause() + eventHub.dispatch(MainTabsChangedEvent(currentTabs)) + } + + companion object { + private const val MIN_TAB_COUNT = 2 + private const val MAX_TAB_COUNT = 5 + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java index 53ffc3dc0..fc278dc9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.java @@ -66,7 +66,8 @@ public class TuskyApplication extends Application implements HasActivityInjector .allowMainThreadQueries() .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, - AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11) + AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, + AppDatabase.MIGRATION_11_12) .build(); accountManager = new AccountManager(appDatabase); serviceLocator = new ServiceLocator() { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt new file mode 100644 index 000000000..66065c7a4 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NetworkStateViewHolder.kt @@ -0,0 +1,45 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import androidx.recyclerview.widget.RecyclerView +import android.view.View +import android.view.ViewGroup +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.Status +import com.keylesspalace.tusky.util.visible +import kotlinx.android.synthetic.main.item_network_state.view.* + +class NetworkStateViewHolder(itemView: View, + private val retryCallback: () -> Unit) +: RecyclerView.ViewHolder(itemView) { + + fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) { + itemView.progressBar.visible(state?.status == Status.RUNNING) + itemView.retryButton.visible(state?.status == Status.FAILED) + itemView.errorMsg.visible(state?.msg != null) + itemView.errorMsg.text = state?.msg + itemView.retryButton.setOnClickListener { + retryCallback() + } + if(fullScreen) { + itemView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + } else { + itemView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java index 294ad0944..2f946108f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java @@ -31,7 +31,7 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder { PlaceholderViewHolder(View itemView) { super(itemView); loadMoreButton = itemView.findViewById(R.id.button_load_more); - progressBar = itemView.findViewById(R.id.progress_bar); + progressBar = itemView.findViewById(R.id.progressBar); } public void setup(final StatusActionListener listener, boolean progress) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index bdcd10c1f..7f1d1a2d6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -44,7 +44,7 @@ import java.lang.CharSequence; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.SparkEventListener; -abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { +public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private TextView displayName; private TextView username; @@ -54,23 +54,23 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private ImageButton moreButton; private boolean favourited; private boolean reblogged; - private MediaPreviewImageView[] mediaPreviews; + protected MediaPreviewImageView[] mediaPreviews; private ImageView[] mediaOverlays; private TextView sensitiveMediaWarning; private View sensitiveMediaShow; - private TextView mediaLabel; + protected TextView mediaLabel; private ToggleButton contentWarningButton; - ImageView avatar; - TextView timestampInfo; - TextView content; - TextView contentWarningDescription; + public ImageView avatar; + public TextView timestampInfo; + public TextView content; + public TextView contentWarningDescription; private boolean useAbsoluteTime; private SimpleDateFormat shortSdf; private SimpleDateFormat longSdf; - StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) { + protected StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); @@ -108,28 +108,30 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected abstract int getMediaPreviewHeight(Context context); - private void setDisplayName(String name, List customEmojis) { + protected void setDisplayName(String name, List customEmojis) { CharSequence emojifiedName = CustomEmojiHelper.emojifyString(name, customEmojis, displayName); displayName.setText(emojifiedName); } - private void setUsername(String name) { + protected void setUsername(String name) { Context context = username.getContext(); String format = context.getString(R.string.status_username_format); String usernameText = String.format(format, name); username.setText(usernameText); } - private void setSpoilerAndContent(StatusViewData.Concrete status, - final StatusActionListener listener) { - if (status.getSpoilerText() == null || status.getSpoilerText().isEmpty()) { + protected void setSpoilerAndContent(boolean expanded, + @NonNull Spanned content, + @Nullable String spoilerText, + @Nullable Status.Mention[] mentions, + @NonNull List emojis, + final StatusActionListener listener) { + if (TextUtils.isEmpty(spoilerText)) { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(true, status, listener); + this.setTextVisible(true, content, mentions, emojis, listener); } else { - boolean expanded = status.isExpanded(); - CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString( - status.getSpoilerText(), status.getStatusEmojis(), contentWarningDescription); + CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription); contentWarningDescription.setText(emojiSpoiler); contentWarningDescription.setVisibility(View.VISIBLE); contentWarningButton.setVisibility(View.VISIBLE); @@ -139,18 +141,19 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (getAdapterPosition() != RecyclerView.NO_POSITION) { listener.onExpandedChange(isChecked, getAdapterPosition()); } - this.setTextVisible(isChecked, status, listener); + this.setTextVisible(isChecked, content, mentions, emojis, listener); }); - this.setTextVisible(expanded, status, listener); + this.setTextVisible(expanded, content, mentions, emojis, listener); } } - private void setTextVisible(boolean expanded, StatusViewData.Concrete status, + private void setTextVisible(boolean expanded, + Spanned content, + Status.Mention[] mentions, + List emojis, final StatusActionListener listener) { - Status.Mention[] mentions = status.getMentions(); if (expanded) { - Spanned emojifiedText = CustomEmojiHelper.emojifyText( - status.getContent(), status.getStatusEmojis(), this.content); + Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content); LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); } else { LinkHelper.setClickableMentions(this.content, mentions, listener); @@ -162,7 +165,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - void setAvatar(String url, @Nullable String rebloggedUrl) { + protected void setAvatar(String url, @Nullable String rebloggedUrl) { if (TextUtils.isEmpty(url)) { avatar.setImageResource(R.drawable.avatar_default); } else { @@ -219,7 +222,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setIsReply(boolean isReply) { + protected void setIsReply(boolean isReply) { if (isReply) { replyButton.setImageResource(R.drawable.ic_reply_all_24dp); } else { @@ -265,13 +268,13 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setFavourited(boolean favourited) { + protected void setFavourited(boolean favourited) { this.favourited = favourited; favouriteButton.setChecked(favourited); } - private void setMediaPreviews(final List attachments, boolean sensitive, - final StatusActionListener listener, boolean showingContent) { + protected void setMediaPreviews(final List attachments, boolean sensitive, + final StatusActionListener listener, boolean showingContent) { Context context = itemView.getContext(); @@ -406,8 +409,8 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setMediaLabel(List attachments, boolean sensitive, - final StatusActionListener listener) { + protected void setMediaLabel(List attachments, boolean sensitive, + final StatusActionListener listener) { if (attachments.size() == 0) { mediaLabel.setVisibility(View.GONE); return; @@ -432,12 +435,12 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { mediaLabel.setOnClickListener(v -> listener.onViewMedia(getAdapterPosition(), 0, null)); } - private void hideSensitiveMediaWarning() { + protected void hideSensitiveMediaWarning() { sensitiveMediaWarning.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.GONE); } - private void setupButtons(final StatusActionListener listener, final String accountId) { + protected void setupButtons(final StatusActionListener listener, final String accountId) { /* Originally position was passed through to all these listeners, but it caused several * bugs where other statuses in the list would be removed or added and cause the position * here to become outdated. So, getting the adapter position at the time the listener is @@ -449,23 +452,25 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { listener.onReply(position); } }); - reblogButton.setEventListener(new SparkEventListener() { - @Override - public void onEvent(ImageView button, boolean buttonState) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onReblog(!reblogged, position); + if(reblogButton != null) { + reblogButton.setEventListener(new SparkEventListener() { + @Override + public void onEvent(ImageView button, boolean buttonState) { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + listener.onReblog(!reblogged, position); + } } - } - @Override - public void onEventAnimationEnd(ImageView button, boolean buttonState) { - } + @Override + public void onEventAnimationEnd(ImageView button, boolean buttonState) { + } - @Override - public void onEventAnimationStart(ImageView button, boolean buttonState) { - } - }); + @Override + public void onEventAnimationStart(ImageView button, boolean buttonState) { + } + }); + } favouriteButton.setEventListener(new SparkEventListener() { @Override public void onEvent(ImageView button, boolean buttonState) { @@ -503,8 +508,8 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { itemView.setOnClickListener(viewThreadListener); } - void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled) { + protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, + boolean mediaPreviewEnabled) { setDisplayName(status.getUserFullName(), status.getAccountEmojis()); setUsername(status.getNickname()); setCreatedAt(status.getCreatedAt()); @@ -535,7 +540,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupButtons(listener, status.getSenderId()); setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility()); - setSpoilerAndContent(status, listener); + setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 7f2a3dc97..4fa2f01cf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -130,8 +130,8 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled) { + protected void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener, + boolean mediaPreviewEnabled) { super.setupWithStatus(status, listener, mediaPreviewEnabled); setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index eae9709b1..895005e9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -49,7 +49,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - void setAvatar(String url, @Nullable String rebloggedUrl) { + protected void setAvatar(String url, @Nullable String rebloggedUrl) { super.setAvatar(url, rebloggedUrl); Context context = avatar.getContext(); @@ -75,8 +75,8 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - boolean mediaPreviewEnabled) { + protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, + boolean mediaPreviewEnabled) { if(status == null) { showContent(false); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt new file mode 100644 index 000000000..286718271 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -0,0 +1,80 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter + +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup + +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.util.ThemeUtils +import kotlinx.android.synthetic.main.item_tab_preference.view.* + +interface ItemInteractionListener { + fun onTabAdded(tab: TabData) + fun onStartDelete(viewHolder: RecyclerView.ViewHolder) + fun onStartDrag(viewHolder: RecyclerView.ViewHolder) +} + +class TabAdapter(var data: List, + val small: Boolean = false, + val listener: ItemInteractionListener? = null) : RecyclerView.Adapter() { + + fun updateData(newData: List) { + this.data = newData + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val layoutId = if(small) { + R.layout.item_tab_preference_small + } else { + R.layout.item_tab_preference + } + val view = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.itemView.textView.setText(data[position].text) + val iconDrawable = ThemeUtils.getTintedDrawable(holder.itemView.context, data[position].icon, android.R.attr.textColorSecondary) + holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null) + if(small) { + holder.itemView.textView.setOnClickListener { + listener?.onTabAdded(data[position]) + } + } + holder.itemView.imageView?.setOnTouchListener { _, event -> + if(event.action == MotionEvent.ACTION_DOWN) { + listener?.onStartDrag(holder) + true + } else { + false + } + } + } + + + + override fun getItemCount(): Int { + return data.size + } + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index fefe08363..0e5cd3fa7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -10,7 +10,7 @@ import javax.inject.Inject class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, - val appDatabase: AppDatabase + private val appDatabase: AppDatabase ) { private val disposable: Disposable diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index db60148e6..15c5b40e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.appstore +import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status @@ -11,4 +12,5 @@ data class MuteEvent(val accountId: String) : Dispatchable data class StatusDeletedEvent(val statusId: String) : Dispatchable data class StatusComposedEvent(val status: Status) : Dispatchable data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable -data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable \ No newline at end of file +data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable +data class MainTabsChangedEvent(val newTabs: List) : Dispatchable \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt new file mode 100644 index 000000000..151b6741c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -0,0 +1,108 @@ +package com.keylesspalace.tusky.components.conversation + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.AsyncPagedListDiffer +import androidx.paging.PagedList +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.adapter.NetworkStateViewHolder +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.util.NetworkState + +class ConversationAdapter(private val useAbsoluteTime: Boolean, + private val mediaPreviewEnabled: Boolean, + private val listener: StatusActionListener, + private val topLoadedCallback: () -> Unit, + private val retryCallback: () -> Unit) + : RecyclerView.Adapter() { + + private var networkState: NetworkState? = null + + private val differ: AsyncPagedListDiffer = AsyncPagedListDiffer(object: ListUpdateCallback { + override fun onInserted(position: Int, count: Int) { + notifyItemRangeInserted(position, count) + if(position == 0) { + topLoadedCallback() + } + } + + override fun onRemoved(position: Int, count: Int) { + notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + notifyItemMoved(fromPosition, toPosition) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + notifyItemRangeChanged(position, count, payload) + } + }, AsyncDifferConfig.Builder(CONVERSATION_COMPARATOR).build()) + + fun submitList(list: PagedList) { + differ.submitList(list) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + return when (viewType) { + R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback) + R.layout.item_conversation -> ConversationViewHolder(view, listener, useAbsoluteTime, mediaPreviewEnabled) + else -> throw IllegalArgumentException("unknown view type $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (getItemViewType(position)) { + R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0) + R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position)) + } + } + + private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED + + override fun getItemViewType(position: Int): Int { + return if (hasExtraRow() && position == itemCount - 1) { + R.layout.item_network_state + } else { + R.layout.item_conversation + } + } + + override fun getItemCount(): Int { + return differ.itemCount + if (hasExtraRow()) 1 else 0 + } + + fun setNetworkState(newNetworkState: NetworkState?) { + val previousState = this.networkState + val hadExtraRow = hasExtraRow() + this.networkState = newNetworkState + val hasExtraRow = hasExtraRow() + if (hadExtraRow != hasExtraRow) { + if (hadExtraRow) { + notifyItemRemoved(differ.itemCount) + } else { + notifyItemInserted(differ.itemCount) + } + } else if (hasExtraRow && previousState != newNetworkState) { + notifyItemChanged(itemCount - 1) + } + } + + companion object { + + val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = + oldItem == newItem + + override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean = + oldItem.id == newItem.id + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt new file mode 100644 index 000000000..85df363dc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -0,0 +1,186 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation + +import android.text.Spanned +import android.text.SpannedString +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.TypeConverters +import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.entity.* +import com.keylesspalace.tusky.util.SmartLengthInputFilter +import java.util.* + +@Entity(primaryKeys = ["id","accountId"]) +@TypeConverters(Converters::class) +data class ConversationEntity( + val accountId: Long, + val id: String, + val accounts: List, + val unread: Boolean, + @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity +) + +data class ConversationAccountEntity( + val id: String, + val username: String, + val displayName: String, + val avatar: String, + val emojis: List +) { + fun toAccount(): Account { + return Account( + id = id, + username = username, + displayName = displayName, + avatar = avatar, + emojis = emojis, + url = "", + localUsername = "", + note = SpannedString(""), + header = "" + ) + } +} + +@TypeConverters(Converters::class) +data class ConversationStatusEntity( + val id: String, + val url: String?, + val inReplyToId: String?, + val inReplyToAccountId: String?, + val account: ConversationAccountEntity, + val content: Spanned, + val createdAt: Date, + val emojis: List, + val favouritesCount: Int, + val favourited: Boolean, + val sensitive: Boolean, + val spoilerText: String, + val attachments: List, + val mentions: Array, + val showingHiddenContent: Boolean, + val expanded: Boolean, + val collapsible: Boolean, + val collapsed: Boolean + +) { + /** its necessary to override this because Spanned.equals does not work as expected */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConversationStatusEntity + + if (id != other.id) return false + if (url != other.url) return false + if (inReplyToId != other.inReplyToId) return false + if (inReplyToAccountId != other.inReplyToAccountId) return false + if (account != other.account) return false + if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings + if (createdAt != other.createdAt) return false + if (emojis != other.emojis) return false + if (favouritesCount != other.favouritesCount) return false + if (favourited != other.favourited) return false + if (sensitive != other.sensitive) return false + if (spoilerText != other.spoilerText) return false + if (attachments != other.attachments) return false + if (!mentions.contentEquals(other.mentions)) return false + if (showingHiddenContent != other.showingHiddenContent) return false + if (expanded != other.expanded) return false + if (collapsible != other.collapsible) return false + if (collapsed != other.collapsed) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + (inReplyToId?.hashCode() ?: 0) + result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) + result = 31 * result + account.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + emojis.hashCode() + result = 31 * result + favouritesCount + result = 31 * result + favourited.hashCode() + result = 31 * result + sensitive.hashCode() + result = 31 * result + spoilerText.hashCode() + result = 31 * result + attachments.hashCode() + result = 31 * result + mentions.contentHashCode() + result = 31 * result + showingHiddenContent.hashCode() + result = 31 * result + expanded.hashCode() + result = 31 * result + collapsible.hashCode() + result = 31 * result + collapsed.hashCode() + return result + } + + fun toStatus(): Status { + return Status( + id = id, + url = url, + account = account.toAccount(), + inReplyToId = inReplyToId, + inReplyToAccountId = inReplyToAccountId, + content = content, + reblog = null, + createdAt = createdAt, + emojis = emojis, + reblogsCount = 0, + favouritesCount = favouritesCount, + reblogged = false, + favourited = favourited, + sensitive= sensitive, + spoilerText = spoilerText, + visibility = Status.Visibility.PRIVATE, + attachments = attachments, + mentions = mentions, + application = null, + pinned = false) + } +} + +fun Account.toEntity() = + ConversationAccountEntity( + id, + username, + displayName, + avatar, + emojis ?: emptyList() + ) + +fun Status.toEntity() = + ConversationStatusEntity( + id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content, + createdAt, emojis, favouritesCount, favourited, sensitive, + spoilerText, attachments, mentions, + false, + false, + !SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT), + true + ) + + +fun Conversation.toEntity(accountId: Long) = + ConversationEntity( + accountId, + id, + accounts.map { it.toEntity() }, + unread, + lastStatus.toEntity() + ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java new file mode 100644 index 000000000..0857960a0 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -0,0 +1,157 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation; + +import android.content.Context; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.ToggleButton; + +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.entity.Attachment; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.squareup.picasso.Picasso; + +import java.util.List; + +import androidx.recyclerview.widget.RecyclerView; + +public class ConversationViewHolder extends StatusBaseViewHolder { + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private TextView conversationNameTextView; + private ToggleButton contentCollapseButton; + private ImageView[] avatars; + + private StatusActionListener listener; + private boolean mediaPreviewEnabled; + + ConversationViewHolder(View itemView, + StatusActionListener listener, + boolean useAbsoluteTime, + boolean mediaPreviewEnabled) { + super(itemView, useAbsoluteTime); + conversationNameTextView = itemView.findViewById(R.id.conversation_name); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); + avatars = new ImageView[]{avatar, itemView.findViewById(R.id.status_avatar_1), itemView.findViewById(R.id.status_avatar_2)}; + + this.listener = listener; + this.mediaPreviewEnabled = mediaPreviewEnabled; + } + + @Override + protected int getMediaPreviewHeight(Context context) { + return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); + } + + void setupWithConversation(ConversationEntity conversation) { + ConversationStatusEntity status = conversation.getLastStatus(); + ConversationAccountEntity account = status.getAccount(); + + setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); + + setDisplayName(account.getDisplayName(), account.getEmojis()); + setUsername(account.getUsername()); + setCreatedAt(status.getCreatedAt()); + setIsReply(status.getInReplyToId() != null); + setFavourited(status.getFavourited()); + List attachments = status.getAttachments(); + boolean sensitive = status.getSensitive(); + if(mediaPreviewEnabled) { + setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent()); + + if (attachments.size() == 0) { + hideSensitiveMediaWarning(); + } + // Hide the unused label. + mediaLabel.setVisibility(View.GONE); + } else { + setMediaLabel(attachments, sensitive, listener); + // Hide all unused views. + mediaPreviews[0].setVisibility(View.GONE); + mediaPreviews[1].setVisibility(View.GONE); + mediaPreviews[2].setVisibility(View.GONE); + mediaPreviews[3].setVisibility(View.GONE); + hideSensitiveMediaWarning(); + } + + setupButtons(listener, account.getId()); + + setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener); + + setConversationName(conversation.getAccounts()); + + setAvatars(conversation.getAccounts()); + + } + + private void setConversationName(List accounts) { + Context context = conversationNameTextView.getContext(); + String conversationName; + if(accounts.size() == 1) { + conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); + } else if(accounts.size() == 2) { + conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername()); + } else { + conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); + } + + conversationNameTextView.setText(conversationName); + } + + private void setAvatars(List accounts) { + for(int i=0; i < avatars.length; i++) { + ImageView avatarView = avatars[i]; + if(i < accounts.size()) { + Picasso.with(avatarView.getContext()) + .load(accounts.get(i).getAvatar()) + .into(avatarView); + avatarView.setVisibility(View.VISIBLE); + } else { + avatarView.setVisibility(View.GONE); + } + } + } + + private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) { + /* input filter for TextViews have to be set before text */ + if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { + contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> { + int position = getAdapterPosition(); + if (position != RecyclerView.NO_POSITION) + listener.onContentCollapsedChange(isChecked, position); + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (collapsed) { + contentCollapseButton.setChecked(true); + content.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setChecked(false); + content.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + content.setFilters(NO_INPUT_FILTER); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt new file mode 100644 index 000000000..5d3590157 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsBoundaryCallback.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.keylesspalace.tusky.components.conversation + +import androidx.annotation.MainThread +import androidx.paging.PagedList +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.PagingRequestHelper +import com.keylesspalace.tusky.util.createStatusLiveData +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.Executor + +/** + * This boundary callback gets notified when user reaches to the edges of the list such that the + * database cannot provide any more data. + *

+ * The boundary callback might be called multiple times for the same direction so it does its own + * rate limiting using the PagingRequestHelper class. + */ +class ConversationsBoundaryCallback( + private val accountId: Long, + private val mastodonApi: MastodonApi, + private val handleResponse: (Long, List?) -> Unit, + private val ioExecutor: Executor, + private val networkPageSize: Int) + : PagedList.BoundaryCallback() { + + val helper = PagingRequestHelper(ioExecutor) + val networkState = helper.createStatusLiveData() + + /** + * Database returned 0 items. We should query the backend for more items. + */ + @MainThread + override fun onZeroItemsLoaded() { + helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) { + mastodonApi.getConversations(null, networkPageSize) + .enqueue(createWebserviceCallback(it)) + } + } + + /** + * User reached to the end of the list. + */ + @MainThread + override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) { + helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) { + mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize) + .enqueue(createWebserviceCallback(it)) + } + } + + /** + * every time it gets new items, boundary callback simply inserts them into the database and + * paging library takes care of refreshing the list if necessary. + */ + private fun insertItemsIntoDb( + response: Response>, + it: PagingRequestHelper.Request.Callback) { + ioExecutor.execute { + handleResponse(accountId, response.body()) + it.recordSuccess() + } + } + + override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) { + // ignored, since we only ever append to what's in the DB + } + + private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback> { + return object : Callback> { + override fun onFailure(call: Call>, t: Throwable) { + it.recordFailure(t) + } + + override fun onResponse(call: Call>, response: Response>) { + insertItemsIntoDb(response, it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt new file mode 100644 index 000000000..040e29363 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -0,0 +1,189 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.components.conversation + +import android.content.Intent +import android.os.Bundle +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.paging.PagedList +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.SimpleItemAnimator +import com.keylesspalace.tusky.AccountActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewTagActivity +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.fragment.SFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.NetworkState +import com.keylesspalace.tusky.util.ThemeUtils +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import kotlinx.android.synthetic.main.fragment_timeline.* +import javax.inject.Inject + +class ConversationsFragment : SFragment(), StatusActionListener, Injectable { + + @Inject + lateinit var timelineCases: TimelineCases + @Inject + lateinit var viewModelFactory: ViewModelFactory + @Inject + lateinit var db: AppDatabase + + private lateinit var viewModel: ConversationsViewModel + + private lateinit var adapter: ConversationAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + viewModel = ViewModelProviders.of(this, viewModelFactory)[ConversationsViewModel::class.java] + + return inflater.inflate(R.layout.fragment_timeline, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + + val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) + val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false) + + val account = accountManager.activeAccount + val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true + + + adapter = ConversationAdapter(useAbsoluteTime, mediaPreviewEnabled,this, ::onTopLoaded, viewModel::retry) + + val divider = DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) + val drawable = ThemeUtils.getDrawable(view.context, R.attr.status_divider_drawable, R.drawable.status_divider_dark) + divider.setDrawable(drawable) + recyclerView.addItemDecoration(divider) + recyclerView.layoutManager = LinearLayoutManager(view.context) + recyclerView.adapter = adapter + (recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false + + progressBar.hide() + statusView.hide() + + initSwipeToRefresh() + + viewModel.conversations.observe(this, Observer> { + adapter.submitList(it) + }) + viewModel.networkState.observe(this, Observer { + adapter.setNetworkState(it) + }) + + viewModel.load() + + } + + private fun initSwipeToRefresh() { + viewModel.refreshState.observe(this, Observer { + swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING + }) + swipeRefreshLayout.setOnRefreshListener { + viewModel.refresh() + } + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)) + } + + private fun onTopLoaded() { + recyclerView.scrollToPosition(0) + } + + override fun onReblog(reblog: Boolean, position: Int) { + // its impossible to reblog private messages + } + + override fun onFavourite(favourite: Boolean, position: Int) { + viewModel.favourite(favourite, position) + } + + override fun onMore(view: View, position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + more(it.toStatus(), view, position) + } + } + + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + viewMedia(attachmentIndex, it.toStatus(), view) + } + } + + override fun onViewThread(position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + viewThread(it.toStatus()) + } + } + + override fun onOpenReblog(position: Int) { + // there are no reblogs in search results + } + + override fun onExpandedChange(expanded: Boolean, position: Int) { + viewModel.expandHiddenStatus(expanded, position) + } + + override fun onContentHiddenChange(isShowing: Boolean, position: Int) { + viewModel.showContent(isShowing, position) + } + + override fun onLoadMore(position: Int) { + // not using the old way of pagination + } + + override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { + viewModel.collapseLongStatus(isCollapsed, position) + } + + override fun onViewAccount(id: String) { + val intent = AccountActivity.getIntent(requireContext(), id) + startActivity(intent) + } + + override fun onViewTag(tag: String) { + val intent = Intent(context, ViewTagActivity::class.java) + intent.putExtra("hashtag", tag) + startActivity(intent) + } + + override fun timelineCases(): TimelineCases { + return timelineCases + } + + override fun removeItem(position: Int) { + viewModel.remove(position) + } + + override fun onReply(position: Int) { + viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let { + reply(it.toStatus()) + } + } + + companion object { + fun newInstance() = ConversationsFragment() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt new file mode 100644 index 000000000..37c5fd42d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRepository.kt @@ -0,0 +1,101 @@ +package com.keylesspalace.tusky.components.conversation + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.paging.Config +import androidx.paging.toLiveData +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.entity.Conversation +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Listing +import com.keylesspalace.tusky.util.NetworkState +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.concurrent.Executors +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) { + + private val ioExecutor = Executors.newSingleThreadExecutor() + + companion object { + private const val DEFAULT_PAGE_SIZE = 20 + } + + @MainThread + fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData { + val networkState = MutableLiveData() + if(showLoadingIndicator) { + networkState.value = NetworkState.LOADING + } + + mastodonApi.getConversations(null, DEFAULT_PAGE_SIZE).enqueue( + object : Callback> { + override fun onFailure(call: Call>, t: Throwable) { + // retrofit calls this on main thread so safe to call set value + networkState.value = NetworkState.error(t.message) + } + + override fun onResponse(call: Call>, response: Response>) { + ioExecutor.execute { + db.runInTransaction { + db.conversationDao().deleteForAccount(accountId) + insertResultIntoDb(accountId, response.body()) + } + // since we are in bg thread now, post the result. + networkState.postValue(NetworkState.LOADED) + } + } + } + ) + return networkState + } + + @MainThread + fun conversations(accountId: Long): Listing { + // create a boundary callback which will observe when the user reaches to the edges of + // the list and update the database with extra data. + val boundaryCallback = ConversationsBoundaryCallback( + accountId = accountId, + mastodonApi = mastodonApi, + handleResponse = this::insertResultIntoDb, + ioExecutor = ioExecutor, + networkPageSize = DEFAULT_PAGE_SIZE) + // we are using a mutable live data to trigger refresh requests which eventually calls + // refresh method and gets a new live data. Each refresh request by the user becomes a newly + // dispatched data in refreshTrigger + val refreshTrigger = MutableLiveData() + val refreshState = Transformations.switchMap(refreshTrigger) { + refresh(accountId, true) + } + + // We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder + val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData( + config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false), + boundaryCallback = boundaryCallback + ) + + return Listing( + pagedList = livePagedList, + networkState = boundaryCallback.networkState, + retry = { + boundaryCallback.helper.retryAllFailed() + }, + refresh = { + refreshTrigger.value = null + }, + refreshState = refreshState + ) + } + + private fun insertResultIntoDb(accountId: Long, result: List?) { + result?.let { conversations -> + db.conversationDao().insert(conversations.map { it.toEntity(accountId) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt new file mode 100644 index 000000000..dfe575f8b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -0,0 +1,104 @@ +package com.keylesspalace.tusky.components.conversation + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.paging.PagedList +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.network.TimelineCases +import com.keylesspalace.tusky.util.Listing +import com.keylesspalace.tusky.util.NetworkState +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.addTo +import javax.inject.Inject + +class ConversationsViewModel @Inject constructor( + private val repository: ConversationsRepository, + private val timelineCases: TimelineCases, + private val database: AppDatabase, + private val accountManager: AccountManager +): ViewModel() { + + private val repoResult = MutableLiveData>() + + val conversations: LiveData> = Transformations.switchMap(repoResult) { it.pagedList } + val networkState: LiveData = Transformations.switchMap(repoResult) { it.networkState } + val refreshState: LiveData = Transformations.switchMap(repoResult) { it.refreshState } + + private val disposables = CompositeDisposable() + + fun load() { + val accountId = accountManager.activeAccount?.id ?: return + if(repoResult.value == null) { + repository.refresh(accountId, false) + } + repoResult.value = repository.conversations(accountId) + } + + fun refresh() { + repoResult.value?.refresh?.invoke() + } + + fun retry() { + repoResult.value?.retry?.invoke() + } + + fun favourite(favourite: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + timelineCases.favourite(conversation.lastStatus.toStatus(), favourite) + .subscribe({ + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(favourited = favourite) + ) + database.conversationDao().insert(newConversation) + }, { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }) + .addTo(disposables) + } + + } + + fun expandHiddenStatus(expanded: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(expanded = expanded) + ) + database.conversationDao().insert(newConversation) + } + } + + fun collapseLongStatus(collapsed: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(collapsed = collapsed) + ) + database.conversationDao().insert(newConversation) + } + } + + fun showContent(showing: Boolean, position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + val newConversation = conversation.copy( + lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) + ) + database.conversationDao().insert(newConversation) + } + } + + fun remove(position: Int) { + conversations.value?.getOrNull(position)?.let { conversation -> + /* this is not ideal since deleting last toot from an conversation + should not delete the conversation but show another toot of the conversation */ + timelineCases.delete(conversation.lastStatus.id) + database.conversationDao().delete(conversation) + } + } + + override fun onCleared() { + disposables.dispose() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 0b234576d..5b3390e2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -19,6 +19,8 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.defaultTabs import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status @@ -48,7 +50,8 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long, var mediaPreviewEnabled: Boolean = true, var lastNotificationId: String = "0", var activeNotifications: String = "[]", - var emojis: List = emptyList()) { + var emojis: List = emptyList(), + var tabPreferences: List = defaultTabs()) { val identifier: String get() = "$domain:$accountId" diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 93feb74f0..7fe0d35e2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -15,6 +15,9 @@ package com.keylesspalace.tusky.db; +import com.keylesspalace.tusky.TabDataKt; +import com.keylesspalace.tusky.components.conversation.ConversationEntity; + import androidx.sqlite.db.SupportSQLiteDatabase; import androidx.room.Database; import androidx.room.RoomDatabase; @@ -25,14 +28,15 @@ import androidx.annotation.NonNull; * DB version & declare DAO */ -@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class,TimelineStatusEntity.class, - TimelineAccountEntity.class - }, version = 11) +@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, + TimelineAccountEntity.class, ConversationEntity.class + }, version = 12) public abstract class AppDatabase extends RoomDatabase { public abstract TootDao tootDao(); public abstract AccountDao accountDao(); public abstract InstanceDao instanceDao(); + public abstract ConversationsDao conversationDao(); public abstract TimelineDao timelineDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @@ -166,4 +170,41 @@ public abstract class AppDatabase extends RoomDatabase { } }; + public static final Migration MIGRATION_11_12 = new Migration(11, 12) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + String defaultTabs = TabDataKt.HOME + ";" + + TabDataKt.NOTIFICATIONS + ";" + + TabDataKt.LOCAL + ";" + + TabDataKt.FEDERATED; + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '" + defaultTabs + "'"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + + "`accountId` INTEGER NOT NULL, " + + "`id` TEXT NOT NULL, " + + "`accounts` TEXT NOT NULL, " + + "`unread` INTEGER NOT NULL, " + + "`s_id` TEXT NOT NULL, " + + "`s_url` TEXT, " + + "`s_inReplyToId` TEXT, " + + "`s_inReplyToAccountId` TEXT, " + + "`s_account` TEXT NOT NULL, " + + "`s_content` TEXT NOT NULL, " + + "`s_createdAt` INTEGER NOT NULL, " + + "`s_emojis` TEXT NOT NULL, " + + "`s_favouritesCount` INTEGER NOT NULL, " + + "`s_favourited` INTEGER NOT NULL, " + + "`s_sensitive` INTEGER NOT NULL, " + + "`s_spoilerText` TEXT NOT NULL, " + + "`s_attachments` TEXT NOT NULL, " + + "`s_mentions` TEXT NOT NULL, " + + "`s_showingHiddenContent` INTEGER NOT NULL, " + + "`s_expanded` INTEGER NOT NULL, " + + "`s_collapsible` INTEGER NOT NULL, " + + "`s_collapsed` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`, `accountId`))"); + + } + }; + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt new file mode 100644 index 000000000..9a4896dfe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt @@ -0,0 +1,40 @@ +/* Copyright 2018 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import androidx.paging.DataSource +import androidx.room.* +import com.keylesspalace.tusky.components.conversation.ConversationEntity + +@Dao +interface ConversationsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(conversations: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(conversation: ConversationEntity) + + @Delete + fun delete(conversation: ConversationEntity) + + @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") + fun conversationsForAccount(accountId: Long) : DataSource.Factory + + @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") + fun deleteForAccount(accountId: Long) + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index a496eaf3b..20181010b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -15,15 +15,25 @@ package com.keylesspalace.tusky.db +import android.text.Spanned import androidx.room.TypeConverter -import com.google.gson.Gson +import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import com.keylesspalace.tusky.TabData +import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity +import com.keylesspalace.tusky.createTabDataFromId +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.json.SpannedTypeAdapter +import com.keylesspalace.tusky.util.HtmlUtils +import java.util.* class Converters { - private val gson = Gson() + private val gson = GsonBuilder() + .registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter()) + .create() @TypeConverter fun jsonToEmojiList(emojiListJson: String?): List? { @@ -36,12 +46,90 @@ class Converters { } @TypeConverter - fun visibilityToInt(visibility: Status.Visibility): Int { - return visibility.num + fun visibilityToInt(visibility: Status.Visibility?): Int { + return visibility?.num ?: Status.Visibility.UNKNOWN.num } @TypeConverter fun intToVisibility(visibility: Int): Status.Visibility { return Status.Visibility.byNum(visibility) } + + @TypeConverter + fun stringToTabData(str: String?): List? { + return str?.split(";") + ?.map { createTabDataFromId(it) } + } + + @TypeConverter + fun tabDataToString(tabData: List?): String? { + return tabData?.joinToString(";") { it.id } + } + + @TypeConverter + fun accountToJson(account: ConversationAccountEntity?): String { + return gson.toJson(account) + } + + @TypeConverter + fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { + return gson.fromJson(accountJson, ConversationAccountEntity::class.java) + } + + @TypeConverter + fun accountListToJson(accountList: List?): String { + return gson.toJson(accountList) + } + + @TypeConverter + fun jsonToAccountList(accountListJson: String?): List? { + return gson.fromJson(accountListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun attachmentListToJson(attachmentList: List?): String { + return gson.toJson(attachmentList) + } + + @TypeConverter + fun jsonToAttachmentList(attachmentListJson: String?): List? { + return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun mentionArrayToJson(mentionArray: Array?): String? { + return gson.toJson(mentionArray) + } + + @TypeConverter + fun jsonToMentionArray(mentionListJson: String?): Array? { + return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + } + + @TypeConverter + fun dateToLong(date: Date): Long { + return date.time + } + + @TypeConverter + fun longToDate(date: Long): Date { + return Date(date) + } + + @TypeConverter + fun spannedToString(spanned: Spanned?): String? { + if(spanned == null) { + return null + } + return HtmlUtils.toHtml(spanned) + } + + @TypeConverter + fun stringToSpanned(spannedString: String?): Spanned? { + if(spannedString == null) { + return null + } + return HtmlUtils.fromHtml(spannedString) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 30e5834cd..a91a2da1f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -86,4 +86,7 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesLicenseActivity(): LicenseActivity + @ContributesAndroidInjector + abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index d344edb5b..df1ee4de7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di +import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.fragment.* import com.keylesspalace.tusky.fragment.preference.* import dagger.Module @@ -51,4 +52,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun accountPreferencesFragment(): AccountPreferencesFragment + @ContributesAndroidInjector + abstract fun directMessagesPreferencesFragment(): ConversationsFragment + } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 3acffc069..bdbe70493 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -46,6 +46,7 @@ import javax.inject.Singleton @Module class NetworkModule { + @Provides @IntoMap @ClassKey(Spanned::class) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index 752a4f24a..0849bac7b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.viewmodel.AccountViewModel +import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.viewmodel.EditProfileViewModel import dagger.Binds import dagger.MapKey @@ -42,5 +43,10 @@ abstract class ViewModelModule { @ViewModelKey(EditProfileViewModel::class) internal abstract fun editProfileViewModel(viewModel: EditProfileViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ConversationsViewModel::class) + internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel + //Add more ViewModels here } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 88df75ce0..d96485c03 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -37,13 +37,13 @@ data class Account( val avatar: String, val header: String, val locked: Boolean = false, - @SerializedName("followers_count") val followersCount: Int, - @SerializedName("following_count") val followingCount: Int, - @SerializedName("statuses_count") val statusesCount: Int, - val source: AccountSource?, - val bot: Boolean, - val emojis: List?, // nullable for backward compatibility - val fields: List?, //nullable for backward compatibility + @SerializedName("followers_count") val followersCount: Int = 0, + @SerializedName("following_count") val followingCount: Int = 0, + @SerializedName("statuses_count") val statusesCount: Int = 0, + val source: AccountSource? = null, + val bot: Boolean = false, + val emojis: List? = emptyList(), // nullable for backward compatibility + val fields: List? = emptyList(), //nullable for backward compatibility val moved: Account? = null ) : Parcelable { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt new file mode 100644 index 000000000..fae186fbb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -0,0 +1,25 @@ +/* Copyright 2019 Conny Duck + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class Conversation( + val id: String, + val accounts: List, + @SerializedName("last_status") val lastStatus: Status, + val unread: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 64b207ab4..05c817a0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -42,7 +42,7 @@ data class Status( var pinned: Boolean? ) { - val actionableId: String? + val actionableId: String get() = reblog?.id ?: id val actionableStatus: Status diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index 8da8da58d..8e801577f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -72,10 +72,10 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { super.onViewCreated(view, savedInstanceState) recyclerView.setHasFixedSize(true) - val layoutManager = LinearLayoutManager(context) + val layoutManager = LinearLayoutManager(view.context) recyclerView.layoutManager = layoutManager - val divider = DividerItemDecoration(context, layoutManager.orientation) - val drawable = ThemeUtils.getDrawable(context, R.attr.status_divider_drawable, R.drawable.status_divider_dark) + val divider = DividerItemDecoration(view.context, layoutManager.orientation) + val drawable = ThemeUtils.getDrawable(view.context, R.attr.status_divider_drawable, R.drawable.status_divider_dark) divider.setDrawable(drawable) recyclerView.addItemDecoration(divider) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt index 106258530..aa928914a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -81,9 +81,10 @@ class AccountMediaFragment : BaseFragment(), Injectable { private val callback = object : Callback> { override fun onFailure(call: Call>?, t: Throwable?) { fetchingStatus = FetchingStatus.NOT_FETCHING - if (isAdded) { - swipe_refresh_layout.isRefreshing = false - progress_bar.visibility = View.GONE + + if(isAdded) { + swipeRefreshLayout.isRefreshing = false + progressBar.visibility = View.GONE statusView.show() if (t is IOException) { statusView.setup(R.drawable.elephant_offline, R.string.error_network) { @@ -101,9 +102,9 @@ class AccountMediaFragment : BaseFragment(), Injectable { override fun onResponse(call: Call>, response: Response>) { fetchingStatus = FetchingStatus.NOT_FETCHING - if (isAdded) { - swipe_refresh_layout.isRefreshing = false - progress_bar.visibility = View.GONE + if(isAdded) { + swipeRefreshLayout.isRefreshing = false + progressBar.visibility = View.GONE val body = response.body() body?.let { fetched -> @@ -114,6 +115,7 @@ class AccountMediaFragment : BaseFragment(), Injectable { result.addAll(AttachmentViewData.list(status)) } adapter.addTop(result) + if (statuses.isEmpty()) { statusView.show() statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, @@ -159,19 +161,19 @@ class AccountMediaFragment : BaseFragment(), Injectable { super.onViewCreated(view, savedInstanceState) - val columnCount = context?.resources?.getInteger(R.integer.profile_media_column_count) ?: 2 - val layoutManager = GridLayoutManager(context, columnCount) + val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) + val layoutManager = GridLayoutManager(view.context, columnCount) - val bgRes = ThemeUtils.getColorId(context, R.attr.window_background) + val bgRes = ThemeUtils.getColorId(view.context, R.attr.window_background) - adapter.baseItemColor = ContextCompat.getColor(recycler_view.context, bgRes) + adapter.baseItemColor = ContextCompat.getColor(recyclerView.context, bgRes) - recycler_view.layoutManager = layoutManager - recycler_view.adapter = adapter + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter val accountId = arguments?.getString(ACCOUNT_ID_ARG) - swipe_refresh_layout.setOnRefreshListener { + swipeRefreshLayout.setOnRefreshListener { statusView.hide() if (fetchingStatus != FetchingStatus.NOT_FETCHING) return@setOnRefreshListener currentCall = if (statuses.isEmpty()) { @@ -184,12 +186,12 @@ class AccountMediaFragment : BaseFragment(), Injectable { currentCall?.enqueue(callback) } - swipe_refresh_layout.setColorSchemeResources(R.color.tusky_blue) - swipe_refresh_layout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground)) + swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue) + swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(view.context, android.R.attr.colorBackground)) statusView.visibility = View.GONE - recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() { + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) { if (dy > 0) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 62a252b52..185f9dede 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -176,9 +176,9 @@ public class NotificationsFragment extends SFragment implements @NonNull Context context = inflater.getContext(); // from inflater to silence warning // Setup the SwipeRefreshLayout. - swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout); - recyclerView = rootView.findViewById(R.id.recycler_view); - progressBar = rootView.findViewById(R.id.progress_bar); + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); + recyclerView = rootView.findViewById(R.id.recyclerView); + progressBar = rootView.findViewById(R.id.progressBar); statusView = rootView.findViewById(R.id.statusView); swipeRefreshLayout.setOnRefreshListener(this); @@ -417,13 +417,13 @@ public class NotificationsFragment extends SFragment implements } @Override - public void onMore(View view, int position) { + public void onMore(@NonNull View view, int position) { Notification notification = notifications.get(position).asRight(); super.more(notification.getStatus(), view, position); } @Override - public void onViewMedia(int position, int attachmentIndex, View view) { + public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { Notification notification = notifications.get(position).asRightOrNull(); if (notification == null || notification.getStatus() == null) return; super.viewMedia(attachmentIndex, notification.getStatus(), view); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index f14abd737..a523a9f14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -98,7 +98,7 @@ public abstract class SFragment extends BaseFragment { } protected void viewThread(Status status) { - bottomSheetActivity.viewThread(status); + bottomSheetActivity.viewThread(status.getActionableId(), status.getUrl()); } protected void viewAccount(String accountId) { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index 4250adfb1..d6d6a21c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -182,14 +182,14 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { } } - override fun onMore(view: View?, position: Int) { + override fun onMore(view: View, position: Int) { val status = searchAdapter.getStatusAtPosition(position) if (status != null) { more(status, view, position) } } - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { + override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) { val status = searchAdapter.getStatusAtPosition(position) ?: return viewMedia(attachmentIndex, status, view) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 7b1da9c98..a5214c800 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -219,9 +219,9 @@ public class TimelineFragment extends SFragment implements Bundle savedInstanceState) { final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false); - recyclerView = rootView.findViewById(R.id.recycler_view); - swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout); - progressBar = rootView.findViewById(R.id.progress_bar); + recyclerView = rootView.findViewById(R.id.recyclerView); + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); + progressBar = rootView.findViewById(R.id.progressBar); statusView = rootView.findViewById(R.id.statusView); setupSwipeRefreshLayout(); @@ -608,7 +608,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onMore(View view, final int position) { + public void onMore(@NonNull View view, final int position) { super.more(statuses.get(position).asRight(), view, position); } @@ -689,7 +689,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onViewMedia(int position, int attachmentIndex, View view) { + public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { Status status = statuses.get(position).asRightOrNull(); if (status == null) return; super.viewMedia(attachmentIndex, status, view); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 399e445d5..865f266b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -137,13 +137,13 @@ public final class ViewThreadFragment extends SFragment implements View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false); Context context = getContext(); - swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout); + swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout); swipeRefreshLayout.setOnRefreshListener(this); swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); swipeRefreshLayout.setProgressBackgroundColorSchemeColor( ThemeUtils.getColor(context, android.R.attr.colorBackground)); - recyclerView = rootView.findViewById(R.id.recycler_view); + recyclerView = rootView.findViewById(R.id.recyclerView); recyclerView.setHasFixedSize(true); LinearLayoutManager layoutManager = new LinearLayoutManager(context); recyclerView.setLayoutManager(layoutManager); @@ -284,12 +284,12 @@ public final class ViewThreadFragment extends SFragment implements } @Override - public void onMore(View view, int position) { + public void onMore(@NonNull View view, int position) { super.more(statuses.get(position), view, position); } @Override - public void onViewMedia(int position, int attachmentIndex, View view) { + public void onViewMedia(int position, int attachmentIndex, @NonNull View view) { Status status = statuses.get(position); super.viewMedia(attachmentIndex, status, view); } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt index c97873fd6..7ce075ae6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/AccountPreferencesFragment.kt @@ -26,10 +26,7 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import android.util.Log import android.view.View -import com.keylesspalace.tusky.AccountListActivity -import com.keylesspalace.tusky.BuildConfig -import com.keylesspalace.tusky.PreferencesActivity -import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.* import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.db.AccountManager @@ -60,6 +57,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), lateinit var eventHub: EventHub private lateinit var notificationPreference: Preference + private lateinit var tabPreference: Preference private lateinit var mutedUsersPreference: Preference private lateinit var blockedUsersPreference: Preference @@ -74,6 +72,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), addPreferencesFromResource(R.xml.account_preferences) notificationPreference = findPreference("notificationPreference") + tabPreference = findPreference("tabPreference") mutedUsersPreference = findPreference("mutedUsersPreference") blockedUsersPreference = findPreference("blockedUsersPreference") defaultPostPrivacyPreference = findPreference("defaultPostPrivacy") as ListPreference @@ -81,11 +80,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), mediaPreviewEnabledPreference = findPreference("mediaPreviewEnabled") as SwitchPreference alwaysShowSensitiveMediaPreference = findPreference("alwaysShowSensitiveMedia") as SwitchPreference - notificationPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint)) + notificationPreference.icon = IconicsDrawable(notificationPreference.context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(notificationPreference.context, R.attr.toolbar_icon_tint)) mutedUsersPreference.icon = getTintedIcon(R.drawable.ic_mute_24dp) - blockedUsersPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint)) + blockedUsersPreference.icon = IconicsDrawable(blockedUsersPreference.context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(blockedUsersPreference.context, R.attr.toolbar_icon_tint)) notificationPreference.onPreferenceClickListener = this + tabPreference.onPreferenceClickListener = this mutedUsersPreference.onPreferenceClickListener = this blockedUsersPreference.onPreferenceClickListener = this @@ -161,6 +161,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), } return true } + tabPreference -> { + val intent = Intent(context, TabPreferenceActivity::class.java) + activity?.startActivity(intent) + activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + return true + } mutedUsersPreference -> { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.MUTES) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt index a6b19bf00..f3aa5bce0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt @@ -34,13 +34,13 @@ class PreferencesFragment : PreferenceFragmentCompat() { addPreferencesFromResource(R.xml.preferences) val themePreference: Preference = findPreference("appTheme") - themePreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_palette).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint)) + themePreference.icon = IconicsDrawable(themePreference.context, GoogleMaterial.Icon.gmd_palette).sizePx(iconSize).color(ThemeUtils.getColor(themePreference.context, R.attr.toolbar_icon_tint)) val emojiPreference: Preference = findPreference("emojiCompat") - emojiPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_sentiment_satisfied).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint)) + emojiPreference.icon = IconicsDrawable(emojiPreference.context, GoogleMaterial.Icon.gmd_sentiment_satisfied).sizePx(iconSize).color(ThemeUtils.getColor(emojiPreference.context, R.attr.toolbar_icon_tint)) val textSizePreference: Preference = findPreference("statusTextSize") - textSizePreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_format_size).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint)) + textSizePreference.icon = IconicsDrawable(textSizePreference.context, GoogleMaterial.Icon.gmd_format_size).sizePx(iconSize).color(ThemeUtils.getColor(textSizePreference.context, R.attr.toolbar_icon_tint)) val timelineFilterPreferences: Preference = findPreference("timelineFilterPreferences") timelineFilterPreferences.setOnPreferenceClickListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index 1ae56d464..31e6230f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -17,12 +17,14 @@ package com.keylesspalace.tusky.interfaces; import android.view.View; +import androidx.annotation.NonNull; + public interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); void onFavourite(final boolean favourite, final int position); - void onMore(View view, final int position); - void onViewMedia(int position, int attachmentIndex, View view); + void onMore(@NonNull View view, final int position); + void onViewMedia(int position, int attachmentIndex, @NonNull View view); void onViewThread(int position); void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); diff --git a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java index c096caaa5..cabd3eb86 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/json/SpannedTypeAdapter.java @@ -22,11 +22,14 @@ import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; import com.keylesspalace.tusky.util.HtmlUtils; import java.lang.reflect.Type; -public class SpannedTypeAdapter implements JsonDeserializer { +public class SpannedTypeAdapter implements JsonDeserializer, JsonSerializer { @Override public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { @@ -37,4 +40,9 @@ public class SpannedTypeAdapter implements JsonDeserializer { return new SpannedString(""); } } + + @Override + public JsonElement serialize(Spanned src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(HtmlUtils.toHtml(src)); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index d73514ae9..bd54d2223 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -20,6 +20,7 @@ import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.AppCredentials; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Card; +import com.keylesspalace.tusky.entity.Conversation; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Instance; import com.keylesspalace.tusky.entity.MastoList; @@ -317,4 +318,7 @@ public interface MastodonApi { @GET("api/v1/instance") Call getInstance(); + + @GET("/api/v1/conversations") + Call> getConversations(@Nullable @Query("max_id") String maxId, @Query("limit") int limit); } diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt new file mode 100644 index 000000000..fef20bf25 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -0,0 +1,46 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.pager + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.viewpager.widget.PagerAdapter +import com.keylesspalace.tusky.TabData + +class MainPagerAdapter(val tabs: List, manager: FragmentManager) : FragmentPagerAdapter(manager) { + + override fun getItem(position: Int): Fragment { + return tabs[position].fragment() + } + + override fun getCount(): Int { + return tabs.size + } + + override fun getPageTitle(position: Int): CharSequence? { + return null + } + + override fun getItemId(position: Int): Long { + return tabs[position].id.hashCode().toLong() + } + + override fun getItemPosition(item: Any): Int { + return PagerAdapter.POSITION_NONE + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/TimelinePagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/pager/TimelinePagerAdapter.java deleted file mode 100644 index 3e26f67b8..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/pager/TimelinePagerAdapter.java +++ /dev/null @@ -1,60 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.pager; - -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentPagerAdapter; - -import com.keylesspalace.tusky.fragment.NotificationsFragment; -import com.keylesspalace.tusky.fragment.TimelineFragment; - -public class TimelinePagerAdapter extends FragmentPagerAdapter { - public TimelinePagerAdapter(FragmentManager manager) { - super(manager); - } - - @Override - public Fragment getItem(int i) { - switch (i) { - case 0: { - return TimelineFragment.newInstance(TimelineFragment.Kind.HOME); - } - case 1: { - return NotificationsFragment.newInstance(); - } - case 2: { - return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL); - } - case 3: { - return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED); - } - default: { - return null; - } - } - } - - @Override - public int getCount() { - return 4; - } - - @Override - public CharSequence getPageTitle(int position) { - return null; - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt new file mode 100644 index 000000000..3d4234c59 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Listing.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList + +/** + * Data class that is necessary for a UI to show a listing and interact w/ the rest of the system + */ +data class Listing( + // the LiveData of paged lists for the UI to observe + val pagedList: LiveData>, + // represents the network request status to show to the user + val networkState: LiveData, + // represents the refresh status to show to the user. Separate from networkState, this + // value is importantly only when refresh is requested. + val refreshState: LiveData, + // refreshes the whole data and fetches it from scratch. + val refresh: () -> Unit, + // retries any failed requests. + val retry: () -> Unit) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt new file mode 100644 index 000000000..09a00339a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/NetworkState.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.keylesspalace.tusky.util + +enum class Status { + RUNNING, + SUCCESS, + FAILED +} + +@Suppress("DataClassPrivateConstructor") +data class NetworkState private constructor( + val status: Status, + val msg: String? = null) { + companion object { + val LOADED = NetworkState(Status.SUCCESS) + val LOADING = NetworkState(Status.RUNNING) + fun error(msg: String?) = NetworkState(Status.FAILED, msg) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java new file mode 100644 index 000000000..43b95b39c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PagingRequestHelper.java @@ -0,0 +1,491 @@ +/* + * Copyright 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.keylesspalace.tusky.util; + +import androidx.annotation.AnyThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import java.util.Arrays; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +/** + * A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and + * {@link DataSource}s to help with tracking network requests. + *

+ * It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL}, + * {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request + * for each of them via {@link #runIfNotRunning(RequestType, Request)}. + *

+ * It tracks a {@link Status} and an {@code error} for each {@link RequestType}. + *

+ * A sample usage of this class to limit requests looks like this: + *

+ * class PagingBoundaryCallback extends PagedList.BoundaryCallback<MyItem> {
+ *     // TODO replace with an executor from your application
+ *     Executor executor = Executors.newSingleThreadExecutor();
+ *     PagingRequestHelper helper = new PagingRequestHelper(executor);
+ *     // imaginary API service, using Retrofit
+ *     MyApi api;
+ *
+ *     {@literal @}Override
+ *     public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
+ *         helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
+ *                 helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
+ *                         new Callback<ApiResponse>() {
+ *                             {@literal @}Override
+ *                             public void onResponse(Call<ApiResponse> call,
+ *                                     Response<ApiResponse> response) {
+ *                                 // TODO insert new records into database
+ *                                 helperCallback.recordSuccess();
+ *                             }
+ *
+ *                             {@literal @}Override
+ *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
+ *                                 helperCallback.recordFailure(t);
+ *                             }
+ *                         }));
+ *     }
+ *
+ *     {@literal @}Override
+ *     public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
+ *         helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
+ *                 helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
+ *                         new Callback<ApiResponse>() {
+ *                             {@literal @}Override
+ *                             public void onResponse(Call<ApiResponse> call,
+ *                                     Response<ApiResponse> response) {
+ *                                 // TODO insert new records into database
+ *                                 helperCallback.recordSuccess();
+ *                             }
+ *
+ *                             {@literal @}Override
+ *                             public void onFailure(Call<ApiResponse> call, Throwable t) {
+ *                                 helperCallback.recordFailure(t);
+ *                             }
+ *                         }));
+ *     }
+ * }
+ * 
+ *

+ * The helper provides an API to observe combined request status, which can be reported back to the + * application based on your business rules. + *

+ * MutableLiveData<PagingRequestHelper.Status> combined = new MutableLiveData<>();
+ * helper.addListener(status -> {
+ *     // merge multiple states per request type into one, or dispatch separately depending on
+ *     // your application logic.
+ *     if (status.hasRunning()) {
+ *         combined.postValue(PagingRequestHelper.Status.RUNNING);
+ *     } else if (status.hasError()) {
+ *         // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
+ *         combined.postValue(PagingRequestHelper.Status.FAILED);
+ *     } else {
+ *         combined.postValue(PagingRequestHelper.Status.SUCCESS);
+ *     }
+ * });
+ * 
+ */ +// THIS class is likely to be moved into the library in a future release. Feel free to copy it +// from this sample. +public class PagingRequestHelper { + private final Object mLock = new Object(); + private final Executor mRetryService; + @GuardedBy("mLock") + private final RequestQueue[] mRequestQueues = new RequestQueue[] + {new RequestQueue(RequestType.INITIAL), + new RequestQueue(RequestType.BEFORE), + new RequestQueue(RequestType.AFTER)}; + @NonNull + final CopyOnWriteArrayList mListeners = new CopyOnWriteArrayList<>(); + /** + * Creates a new PagingRequestHelper with the given {@link Executor} which is used to run + * retry actions. + * + * @param retryService The {@link Executor} that can run the retry actions. + */ + public PagingRequestHelper(@NonNull Executor retryService) { + mRetryService = retryService; + } + /** + * Adds a new listener that will be notified when any request changes {@link Status state}. + * + * @param listener The listener that will be notified each time a request's status changes. + * @return True if it is added, false otherwise (e.g. it already exists in the list). + */ + @AnyThread + public boolean addListener(@NonNull Listener listener) { + return mListeners.add(listener); + } + /** + * Removes the given listener from the listeners list. + * + * @param listener The listener that will be removed. + * @return True if the listener is removed, false otherwise (e.g. it never existed) + */ + public boolean removeListener(@NonNull Listener listener) { + return mListeners.remove(listener); + } + /** + * Runs the given {@link Request} if no other requests in the given request type is already + * running. + *

+ * If run, the request will be run in the current thread. + * + * @param type The type of the request. + * @param request The request to run. + * @return True if the request is run, false otherwise. + */ + @SuppressWarnings("WeakerAccess") + @AnyThread + public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) { + boolean hasListeners = !mListeners.isEmpty(); + StatusReport report = null; + synchronized (mLock) { + RequestQueue queue = mRequestQueues[type.ordinal()]; + if (queue.mRunning != null) { + return false; + } + queue.mRunning = request; + queue.mStatus = Status.RUNNING; + queue.mFailed = null; + queue.mLastError = null; + if (hasListeners) { + report = prepareStatusReportLocked(); + } + } + if (report != null) { + dispatchReport(report); + } + final RequestWrapper wrapper = new RequestWrapper(request, this, type); + wrapper.run(); + return true; + } + @GuardedBy("mLock") + private StatusReport prepareStatusReportLocked() { + Throwable[] errors = new Throwable[]{ + mRequestQueues[0].mLastError, + mRequestQueues[1].mLastError, + mRequestQueues[2].mLastError + }; + return new StatusReport( + getStatusForLocked(RequestType.INITIAL), + getStatusForLocked(RequestType.BEFORE), + getStatusForLocked(RequestType.AFTER), + errors + ); + } + @GuardedBy("mLock") + private Status getStatusForLocked(RequestType type) { + return mRequestQueues[type.ordinal()].mStatus; + } + @AnyThread + @VisibleForTesting + void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) { + StatusReport report = null; + final boolean success = throwable == null; + boolean hasListeners = !mListeners.isEmpty(); + synchronized (mLock) { + RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()]; + queue.mRunning = null; + queue.mLastError = throwable; + if (success) { + queue.mFailed = null; + queue.mStatus = Status.SUCCESS; + } else { + queue.mFailed = wrapper; + queue.mStatus = Status.FAILED; + } + if (hasListeners) { + report = prepareStatusReportLocked(); + } + } + if (report != null) { + dispatchReport(report); + } + } + private void dispatchReport(StatusReport report) { + for (Listener listener : mListeners) { + listener.onStatusChange(report); + } + } + /** + * Retries all failed requests. + * + * @return True if any request is retried, false otherwise. + */ + public boolean retryAllFailed() { + final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length]; + boolean retried = false; + synchronized (mLock) { + for (int i = 0; i < RequestType.values().length; i++) { + toBeRetried[i] = mRequestQueues[i].mFailed; + mRequestQueues[i].mFailed = null; + } + } + for (RequestWrapper failed : toBeRetried) { + if (failed != null) { + failed.retry(mRetryService); + retried = true; + } + } + return retried; + } + static class RequestWrapper implements Runnable { + @NonNull + final Request mRequest; + @NonNull + final PagingRequestHelper mHelper; + @NonNull + final RequestType mType; + RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper, + @NonNull RequestType type) { + mRequest = request; + mHelper = helper; + mType = type; + } + @Override + public void run() { + mRequest.run(new Request.Callback(this, mHelper)); + } + void retry(Executor service) { + service.execute(new Runnable() { + @Override + public void run() { + mHelper.runIfNotRunning(mType, mRequest); + } + }); + } + } + /** + * Runner class that runs a request tracked by the {@link PagingRequestHelper}. + *

+ * When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)} + * or {@link Callback#recordSuccess()} once and only once. This call + * can be made any time. Until that method call is made, {@link PagingRequestHelper} will + * consider the request is running. + */ + @FunctionalInterface + public interface Request { + /** + * Should run the request and call the given {@link Callback} with the result of the + * request. + * + * @param callback The callback that should be invoked with the result. + */ + void run(Callback callback); + /** + * Callback class provided to the {@link #run(Callback)} method to report the result. + */ + class Callback { + private final AtomicBoolean mCalled = new AtomicBoolean(); + private final RequestWrapper mWrapper; + private final PagingRequestHelper mHelper; + Callback(RequestWrapper wrapper, PagingRequestHelper helper) { + mWrapper = wrapper; + mHelper = helper; + } + /** + * Call this method when the request succeeds and new data is fetched. + */ + @SuppressWarnings("unused") + public final void recordSuccess() { + if (mCalled.compareAndSet(false, true)) { + mHelper.recordResult(mWrapper, null); + } else { + throw new IllegalStateException( + "already called recordSuccess or recordFailure"); + } + } + /** + * Call this method with the failure message and the request can be retried via + * {@link #retryAllFailed()}. + * + * @param throwable The error that occured while carrying out the request. + */ + @SuppressWarnings("unused") + public final void recordFailure(@NonNull Throwable throwable) { + //noinspection ConstantConditions + if (throwable == null) { + throw new IllegalArgumentException("You must provide a throwable describing" + + " the error to record the failure"); + } + if (mCalled.compareAndSet(false, true)) { + mHelper.recordResult(mWrapper, throwable); + } else { + throw new IllegalStateException( + "already called recordSuccess or recordFailure"); + } + } + } + } + /** + * Data class that holds the information about the current status of the ongoing requests + * using this helper. + */ + public static final class StatusReport { + /** + * Status of the latest request that were submitted with {@link RequestType#INITIAL}. + */ + @NonNull + public final Status initial; + /** + * Status of the latest request that were submitted with {@link RequestType#BEFORE}. + */ + @NonNull + public final Status before; + /** + * Status of the latest request that were submitted with {@link RequestType#AFTER}. + */ + @NonNull + public final Status after; + @NonNull + private final Throwable[] mErrors; + StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after, + @NonNull Throwable[] errors) { + this.initial = initial; + this.before = before; + this.after = after; + this.mErrors = errors; + } + /** + * Convenience method to check if there are any running requests. + * + * @return True if there are any running requests, false otherwise. + */ + public boolean hasRunning() { + return initial == Status.RUNNING + || before == Status.RUNNING + || after == Status.RUNNING; + } + /** + * Convenience method to check if there are any requests that resulted in an error. + * + * @return True if there are any requests that finished with error, false otherwise. + */ + public boolean hasError() { + return initial == Status.FAILED + || before == Status.FAILED + || after == Status.FAILED; + } + /** + * Returns the error for the given request type. + * + * @param type The request type for which the error should be returned. + * @return The {@link Throwable} returned by the failing request with the given type or + * {@code null} if the request for the given type did not fail. + */ + @Nullable + public Throwable getErrorFor(@NonNull RequestType type) { + return mErrors[type.ordinal()]; + } + @Override + public String toString() { + return "StatusReport{" + + "initial=" + initial + + ", before=" + before + + ", after=" + after + + ", mErrors=" + Arrays.toString(mErrors) + + '}'; + } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StatusReport that = (StatusReport) o; + if (initial != that.initial) return false; + if (before != that.before) return false; + if (after != that.after) return false; + // Probably incorrect - comparing Object[] arrays with Arrays.equals + return Arrays.equals(mErrors, that.mErrors); + } + @Override + public int hashCode() { + int result = initial.hashCode(); + result = 31 * result + before.hashCode(); + result = 31 * result + after.hashCode(); + result = 31 * result + Arrays.hashCode(mErrors); + return result; + } + } + /** + * Listener interface to get notified by request status changes. + */ + public interface Listener { + /** + * Called when the status for any of the requests has changed. + * + * @param report The current status report that has all the information about the requests. + */ + void onStatusChange(@NonNull StatusReport report); + } + /** + * Represents the status of a Request for each {@link RequestType}. + */ + public enum Status { + /** + * There is current a running request. + */ + RUNNING, + /** + * The last request has succeeded or no such requests have ever been run. + */ + SUCCESS, + /** + * The last request has failed. + */ + FAILED + } + /** + * Available request types. + */ + public enum RequestType { + /** + * Corresponds to an initial request made to a {@link DataSource} or the empty state for + * a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + INITIAL, + /** + * Corresponds to the {@code loadBefore} calls in {@link DataSource} or + * {@code onItemAtFrontLoaded} in + * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + BEFORE, + /** + * Corresponds to the {@code loadAfter} calls in {@link DataSource} or + * {@code onItemAtEndLoaded} in + * {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}. + */ + AFTER + } + class RequestQueue { + @NonNull + final RequestType mRequestType; + @Nullable + RequestWrapper mFailed; + @Nullable + Request mRunning; + @Nullable + Throwable mLastError; + @NonNull + Status mStatus = Status.SUCCESS; + RequestQueue(@NonNull RequestType requestType) { + mRequestType = requestType; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java index b1329cba9..2ba900e7d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.java @@ -25,7 +25,8 @@ import androidx.annotation.AttrRes; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DrawableRes; -import androidx.core.content.ContextCompat; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatDelegate; import android.util.TypedValue; import android.widget.ImageView; @@ -37,12 +38,12 @@ import android.widget.ImageView; public class ThemeUtils { public static final String APP_THEME_DEFAULT = ThemeUtils.THEME_NIGHT; - public static final String THEME_NIGHT = "night"; - public static final String THEME_DAY = "day"; - public static final String THEME_BLACK = "black"; - public static final String THEME_AUTO = "auto"; + private static final String THEME_NIGHT = "night"; + private static final String THEME_DAY = "day"; + private static final String THEME_BLACK = "black"; + private static final String THEME_AUTO = "auto"; - public static Drawable getDrawable(Context context, @AttrRes int attribute, + public static Drawable getDrawable(@NonNull Context context, @AttrRes int attribute, @DrawableRes int fallbackDrawable) { TypedValue value = new TypedValue(); @DrawableRes int resourceId; @@ -51,10 +52,10 @@ public class ThemeUtils { } else { resourceId = fallbackDrawable; } - return ContextCompat.getDrawable(context, resourceId); + return context.getDrawable(resourceId); } - public static @DrawableRes int getDrawableId(Context context, @AttrRes int attribute, + public static @DrawableRes int getDrawableId(@NonNull Context context, @AttrRes int attribute, @DrawableRes int fallbackDrawableId) { TypedValue value = new TypedValue(); if (context.getTheme().resolveAttribute(attribute, value, true)) { @@ -64,7 +65,7 @@ public class ThemeUtils { } } - public static @ColorInt int getColor(Context context, @AttrRes int attribute) { + public static @ColorInt int getColor(@NonNull Context context, @AttrRes int attribute) { TypedValue value = new TypedValue(); if (context.getTheme().resolveAttribute(attribute, value, true)) { return value.data; @@ -73,13 +74,13 @@ public class ThemeUtils { } } - public static @ColorRes int getColorId(Context context, @AttrRes int attribute) { + public static @ColorRes int getColorId(@NonNull Context context, @AttrRes int attribute) { TypedValue value = new TypedValue(); context.getTheme().resolveAttribute(attribute, value, true); return value.resourceId; } - public static @ColorInt int getColorById(Context context, String name) { + public static @ColorInt int getColorById(@NonNull Context context, String name) { return getColor(context, ResourcesUtils.getResourceIdentifier(context, "attr", name)); } @@ -88,6 +89,16 @@ public class ThemeUtils { view.setColorFilter(getColor(view.getContext(), attribute), PorterDuff.Mode.SRC_IN); } + /** this can be replaced with drawableTint in xml once minSdkVersion >= 23 */ + public static @Nullable Drawable getTintedDrawable(@NonNull Context context, @DrawableRes int drawableId, @AttrRes int colorAttr) { + Drawable drawable = context.getDrawable(drawableId); + if(drawable == null) { + return null; + } + setDrawableTint(context, drawable, colorAttr); + return drawable; + } + public static void setDrawableTint(Context context, Drawable drawable, @AttrRes int attribute) { drawable.setColorFilter(getColor(context, attribute), PorterDuff.Mode.SRC_IN); } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt new file mode 100644 index 000000000..b003cb2d5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/getErrorMessage.kt @@ -0,0 +1,23 @@ +package com.keylesspalace.tusky.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String { + return PagingRequestHelper.RequestType.values().mapNotNull { + report.getErrorFor(it)?.message + }.first() +} + +fun PagingRequestHelper.createStatusLiveData(): LiveData { + val liveData = MutableLiveData() + addListener { report -> + when { + report.hasRunning() -> liveData.postValue(NetworkState.LOADING) + report.hasError() -> liveData.postValue( + NetworkState.error(getErrorMessage(report))) + else -> liveData.postValue(NetworkState.LOADED) + } + } + return liveData +} \ No newline at end of file diff --git a/app/src/main/res/color/tab_icon_color.xml b/app/src/main/res/color/tab_icon_color.xml new file mode 100644 index 000000000..8edd8d373 --- /dev/null +++ b/app/src/main/res/color/tab_icon_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_border.xml b/app/src/main/res/drawable/avatar_border.xml new file mode 100644 index 000000000..1053951c2 --- /dev/null +++ b/app/src/main/res/drawable/avatar_border.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drag_indicator_24dp.xml b/app/src/main/res/drawable/ic_drag_indicator_24dp.xml new file mode 100644 index 000000000..ab9d5f320 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_indicator_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_plus_24dp.xml b/app/src/main/res/drawable/ic_plus_24dp.xml new file mode 100644 index 000000000..2ba0da888 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index ef5743af9..2fe2d3375 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -13,19 +13,19 @@ android:background="?attr/window_background"> - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 60e491d1e..c12b11037 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -36,33 +36,12 @@ android:background="?android:colorBackground" android:elevation="@dimen/actionbar_elevation" app:tabGravity="fill" - app:tabMaxWidth="0dp" + app:tabIconTint="@color/tab_icon_color" + app:tabMode="fixed" app:tabPaddingEnd="1dp" app:tabPaddingStart="1dp" app:tabPaddingTop="4dp" - app:tabUnboundedRipple="false"> - - - - - - - - - - + app:tabUnboundedRipple="false" /> diff --git a/app/src/main/res/layout/activity_tab_preference.xml b/app/src/main/res/layout/activity_tab_preference.xml new file mode 100644 index 000000000..537d74767 --- /dev/null +++ b/app/src/main/res/layout/activity_tab_preference.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_timeline.xml b/app/src/main/res/layout/fragment_timeline.xml index 7a9381f75..284b0083d 100644 --- a/app/src/main/res/layout/fragment_timeline.xml +++ b/app/src/main/res/layout/fragment_timeline.xml @@ -6,19 +6,19 @@ android:layout_height="match_parent"> diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml new file mode 100644 index 000000000..bcf9e0239 --- /dev/null +++ b/app/src/main/res/layout/item_conversation.xml @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_network_state.xml b/app/src/main/res/layout/item_network_state.xml new file mode 100644 index 000000000..1674c3034 --- /dev/null +++ b/app/src/main/res/layout/item_network_state.xml @@ -0,0 +1,23 @@ + + + + +