Merge branch 'develop' into cleanup_unused_resources
# Conflicts: # app/lint-baseline.xml
This commit is contained in:
commit
9a93a64033
|
@ -0,0 +1,39 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '11'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Gradle Wrapper Validation
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Gradle Build Action
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: ktlint
|
||||
run: ./gradlew clean ktlintCheck
|
||||
|
||||
- name: Regular lint
|
||||
run: ./gradlew lintGreenDebug
|
||||
|
||||
- name: Test
|
||||
run: ./gradlew app:testGreenDebugUnitTest
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew app:greenDebug
|
147
CHANGELOG.md
147
CHANGELOG.md
|
@ -6,6 +6,153 @@
|
|||
|
||||
### Significant bug fixes
|
||||
|
||||
## v23.0
|
||||
|
||||
### New features and other improvements
|
||||
|
||||
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
|
||||
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
|
||||
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
|
||||
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
|
||||
## v23.0 beta 2
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
|
||||
## v23.0 beta 1
|
||||
|
||||
### New features and other improvements
|
||||
|
||||
- **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
- If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account.
|
||||
- **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below
|
||||
- **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes.
|
||||
- **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck)
|
||||
|
||||
## v22.0
|
||||
|
||||
### New features and other improvements
|
||||
|
||||
- **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos)
|
||||
- View trending hashtags from the side menu, or by adding them to a new tab.
|
||||
- **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- Edit image descriptions and focus points when editing posts.
|
||||
- **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- Tap the banner image on any profile to view it full size, save, share, etc.
|
||||
- **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Follow new hashtags from the "Followed hashtags" screen.
|
||||
- **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in.
|
||||
- **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja)
|
||||
- Adjusted the design so the "Load more" break in a timeline is more obvious.
|
||||
- **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices.
|
||||
- **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Notifications no longer need to "Load more", they are loaded automatically as you scroll.
|
||||
- Errors when interacting with notifications are displayed to the user, with a "Retry" option.
|
||||
- **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions.
|
||||
- **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters).
|
||||
- **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413)
|
||||
- Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline.
|
||||
- **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible.
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs.
|
||||
- **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer)
|
||||
- A regression from v21.0 where the media player controls could not be used.
|
||||
- **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja)
|
||||
- Opening Tusky would dismiss all active Tusky Android notifications.
|
||||
- **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is.
|
||||
- **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly.
|
||||
- **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak)
|
||||
- Editing a post in thread view would show the old and new version of the post in the thread.
|
||||
- **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton)
|
||||
- In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors.
|
||||
- **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja)
|
||||
- Finishing editing an image caption before the image had finished loading would lose the caption.
|
||||
- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688)
|
||||
|
||||
## v22.0 beta 7
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Fetch all outstanding Mastodon notifications when creating Android notifications**, [PR#3700](https://github.com/tuskyapp/Tusky/pull/3700)
|
||||
- **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688)
|
||||
- **Ensure "last read notification ID" is saved to the correct account**, [PR#3697](https://github.com/tuskyapp/Tusky/pull/3697)
|
||||
|
||||
## v22.0 beta 6
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Save reading position in the Notifications tab more frequently**, [PR#3685](https://github.com/tuskyapp/Tusky/pull/3685)
|
||||
|
||||
## v22.0 beta 5
|
||||
|
||||
## Significant bug fixes
|
||||
|
||||
- **Rolled back APNG library to fix broken animated emojis**, [PR#3676](https://github.com/tuskyapp/Tusky/pull/3676)
|
||||
- **Save local copy of notification marker in case server does not support the API**, [PR#3672](https://github.com/tuskyapp/Tusky/pull/3672)
|
||||
|
||||
## v22.0 beta 4
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Fixed repeated fetch of notifications if configured with multiple accounts**, [PR#3660](https://github.com/tuskyapp/Tusky/pull/3660)
|
||||
|
||||
## v22.0 beta 3
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Fixed crash when viewing a thread**, [PR#3622](https://github.com/tuskyapp/Tusky/pull/3622)
|
||||
- **Fixed crash processing Mastodon filters**, [PR#3634](https://github.com/tuskyapp/Tusky/pull/3634)
|
||||
- **Links in bios of follow/follow request notifications are clickable**, [PR#3646](https://github.com/tuskyapp/Tusky/pull/3646)
|
||||
- **Android Notifications updates**, [PR#3636](https://github.com/tuskyapp/Tusky/pull/3626)
|
||||
- Android notification for a Mastodon notification should only be shown once
|
||||
- Android notifications are grouped by Mastodon notification type (follow, mention, boost, etc)
|
||||
- Potential for missing notifications has been removed
|
||||
|
||||
## v22.0 beta 2
|
||||
|
||||
### Significant bug fixes
|
||||
|
||||
- **Improved notification loading speed**, [PR#3598](https://github.com/tuskyapp/Tusky/pull/3598)
|
||||
- **Restore showing 0/1/1+ for replies**, [PR#3590](https://github.com/tuskyapp/Tusky/pull/3590)
|
||||
- **Show filter titles, not filter keywords, on filtered posts**, [PR#3589](https://github.com/tuskyapp/Tusky/pull/3589)
|
||||
- **Fixed a bug where opening a status could open an unrelated link**, [PR#3600](https://github.com/tuskyapp/Tusky/pull/3600)
|
||||
- **Show "Add" button in correct place when there are no filters**, [PR#3561](https://github.com/tuskyapp/Tusky/pull/3561)
|
||||
- **Fixed assorted crashes**
|
||||
|
||||
## v22.0 beta 1
|
||||
|
||||
### New features and other improvements
|
||||
|
|
|
@ -31,11 +31,5 @@ If you have any bug reports, feature requests or questions please open an issue
|
|||
### Contributing
|
||||
We always welcome new contributors! Please read our [contribution guide](https://github.com/tuskyapp/Tusky/blob/develop/CONTRIBUTING.md) to get started.
|
||||
|
||||
### Head of development
|
||||
|
||||
This app was developed by [Vavassor@mastodon.social](https://mastodon.social/@Vavassor).
|
||||
The current maintainer is [ConnyDuck@chaos.social](https://chaos.social/@ConnyDuck).
|
||||
|
||||
### Development chatroom
|
||||
https://riot.im/app/#/room/#Tusky:matrix.org
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.google.ksp)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
|
@ -28,8 +29,8 @@ android {
|
|||
namespace "com.keylesspalace.tusky"
|
||||
minSdk 23
|
||||
targetSdk 33
|
||||
versionCode 103
|
||||
versionName "22.0 beta 1"
|
||||
versionCode 113
|
||||
versionName "23.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
|
@ -113,11 +114,9 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.incremental", "true")
|
||||
}
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.incremental", "true")
|
||||
}
|
||||
|
||||
configurations {
|
||||
|
@ -134,7 +133,7 @@ dependencies {
|
|||
|
||||
implementation libs.bundles.androidx
|
||||
implementation libs.bundles.room
|
||||
kapt libs.androidx.room.compiler
|
||||
ksp libs.androidx.room.compiler
|
||||
|
||||
implementation libs.android.material
|
||||
|
||||
|
@ -148,7 +147,7 @@ dependencies {
|
|||
implementation libs.conscrypt.android
|
||||
|
||||
implementation libs.bundles.glide
|
||||
kapt libs.glide.compiler
|
||||
ksp libs.glide.compiler
|
||||
|
||||
implementation libs.bundles.rxjava3
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -30,9 +30,16 @@
|
|||
Disable these for the time being. -->
|
||||
<issue id="UnusedIds" severity="ignore" />
|
||||
|
||||
<!-- Logs are stripped in release builds. -->
|
||||
<issue id="LogConditional" severity="ignore" />
|
||||
|
||||
<!-- Ensure we are warned about errors in the baseline -->
|
||||
<issue id="LintBaseline" severity="warning" />
|
||||
|
||||
<!-- Warn about typos. The typo database in lint is not exhaustive, and it's unclear
|
||||
how to add to it when it's wrong. -->
|
||||
<issue id="Typos" severity="warning" />
|
||||
|
||||
<!-- Mark all other lint issues as errors -->
|
||||
<issue id="all" severity="error" />
|
||||
</lint>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,995 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 50,
|
||||
"identityHash": "4eaf69e915d4a15f021547b725101acd",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSendNew",
|
||||
"columnName": "failedToSendNew",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scheduledAt",
|
||||
"columnName": "scheduledAt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "language",
|
||||
"columnName": "language",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "statusId",
|
||||
"columnName": "statusId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"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, `clientId` TEXT, `clientSecret` TEXT, `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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT)",
|
||||
"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": "clientId",
|
||||
"columnName": "clientId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "clientSecret",
|
||||
"columnName": "clientSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"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": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReports",
|
||||
"columnName": "notificationsReports",
|
||||
"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": "defaultPostLanguage",
|
||||
"columnName": "defaultPostLanguage",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "alwaysShowSensitiveMedia",
|
||||
"columnName": "alwaysShowSensitiveMedia",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "alwaysOpenSpoiler",
|
||||
"columnName": "alwaysOpenSpoiler",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaPreviewEnabled",
|
||||
"columnName": "mediaPreviewEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastNotificationId",
|
||||
"columnName": "lastNotificationId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojis",
|
||||
"columnName": "emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tabPreferences",
|
||||
"columnName": "tabPreferences",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFilter",
|
||||
"columnName": "notificationsFilter",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "oauthScopes",
|
||||
"columnName": "oauthScopes",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "unifiedPushUrl",
|
||||
"columnName": "unifiedPushUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPubKey",
|
||||
"columnName": "pushPubKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushPrivKey",
|
||||
"columnName": "pushPrivKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushAuth",
|
||||
"columnName": "pushAuth",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pushServerKey",
|
||||
"columnName": "pushServerKey",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVisibleHomeTimelineStatusId",
|
||||
"columnName": "lastVisibleHomeTimelineStatusId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "videoSizeLimit",
|
||||
"columnName": "videoSizeLimit",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageSizeLimit",
|
||||
"columnName": "imageSizeLimit",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "imageMatrixLimit",
|
||||
"columnName": "imageMatrixLimit",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxMediaAttachments",
|
||||
"columnName": "maxMediaAttachments",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxFields",
|
||||
"columnName": "maxFields",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxFieldNameLength",
|
||||
"columnName": "maxFieldNameLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxFieldValueLength",
|
||||
"columnName": "maxFieldValueLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"instance"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "TimelineStatusEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` 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": "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": "editedAt",
|
||||
"columnName": "editedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"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": "repliesCount",
|
||||
"columnName": "repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogged",
|
||||
"columnName": "reblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookmarked",
|
||||
"columnName": "bookmarked",
|
||||
"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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "card",
|
||||
"columnName": "card",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "language",
|
||||
"columnName": "language",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "filtered",
|
||||
"columnName": "filtered",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "ConversationEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER 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_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"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.editedAt",
|
||||
"columnName": "s_editedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.emojis",
|
||||
"columnName": "s_emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favouritesCount",
|
||||
"columnName": "s_favouritesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.repliesCount",
|
||||
"columnName": "s_repliesCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.favourited",
|
||||
"columnName": "s_favourited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.bookmarked",
|
||||
"columnName": "s_bookmarked",
|
||||
"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.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.language",
|
||||
"columnName": "s_language",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, '4eaf69e915d4a15f021547b725101acd')"
|
||||
]
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -155,8 +155,6 @@
|
|||
<activity android:name="com.keylesspalace.tusky.components.filters.EditFilterActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<receiver android:name=".receiver.NotificationClearBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".receiver.SendStatusBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
|
|
|
@ -46,7 +46,6 @@ import com.keylesspalace.tusky.util.viewBinding
|
|||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.State
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
|
||||
|
@ -146,23 +145,10 @@ class AccountsInListFragment : DialogFragment(), Injectable {
|
|||
|
||||
private fun handleError(error: Throwable) {
|
||||
binding.messageView.show()
|
||||
val retryAction = { _: View ->
|
||||
binding.messageView.setup(error) { _: View ->
|
||||
binding.messageView.hide()
|
||||
viewModel.load(listId)
|
||||
}
|
||||
if (error is IOException) {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network,
|
||||
retryAction
|
||||
)
|
||||
} else {
|
||||
binding.messageView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic,
|
||||
retryAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRemoveFromList(accountId: String) {
|
||||
|
|
|
@ -16,9 +16,11 @@
|
|||
package com.keylesspalace.tusky;
|
||||
|
||||
import android.app.ActivityManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Color;
|
||||
|
@ -45,6 +47,7 @@ import com.keylesspalace.tusky.db.AccountManager;
|
|||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
|
||||
import com.keylesspalace.tusky.interfaces.PermissionRequester;
|
||||
import com.keylesspalace.tusky.settings.PrefKeys;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
@ -54,6 +57,7 @@ import java.util.List;
|
|||
import javax.inject.Inject;
|
||||
|
||||
public abstract class BaseActivity extends AppCompatActivity implements Injectable {
|
||||
private static final String TAG = "BaseActivity";
|
||||
|
||||
@Inject
|
||||
public AccountManager accountManager;
|
||||
|
@ -93,6 +97,44 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
requesters = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context newBase) {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase);
|
||||
|
||||
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
|
||||
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
|
||||
|
||||
Configuration configuration = newBase.getResources().getConfiguration();
|
||||
|
||||
// Adjust `fontScale` in the configuration.
|
||||
//
|
||||
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
|
||||
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
|
||||
// you to the original 100%, it leaves it at 80%.
|
||||
//
|
||||
// Instead, calculate the new scale from the application context. This is unaffected by
|
||||
// changes to the base context. It does contain contain any changes to the font scale from
|
||||
// "Settings > Display > Font size" in the device settings, so scaling performed here
|
||||
// is in addition to any scaling in the device settings.
|
||||
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
|
||||
|
||||
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
|
||||
// You can try to adjust `densityDpi` as shown in the commented out code below. This
|
||||
// works, to a point. However, dialogs do not react well to this. Beyond a certain
|
||||
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
|
||||
// screen.
|
||||
//
|
||||
// So for now, just adjust the font scale
|
||||
//
|
||||
// val displayMetrics = appContext.resources.displayMetrics
|
||||
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
|
||||
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
|
||||
|
||||
Context fontScaleContext = newBase.createConfigurationContext(configuration);
|
||||
|
||||
super.attachBaseContext(fontScaleContext);
|
||||
}
|
||||
|
||||
protected boolean requiresLogin() {
|
||||
return true;
|
||||
}
|
||||
|
@ -213,7 +255,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
|
|||
}
|
||||
|
||||
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
|
||||
accountManager.setActiveAccount(account);
|
||||
accountManager.setActiveAccount(account.getId());
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.putExtra(MainActivity.REDIRECT_URL, url);
|
||||
|
|
|
@ -737,7 +737,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
onTabSelectedListener = object : OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
|
||||
binding.mainToolbar.title = tab.contentDescription
|
||||
|
||||
refreshComposeButtonState(tabAdapter, tab.position)
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ import android.os.Bundle
|
|||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
|
@ -42,12 +44,12 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
|
||||
import com.keylesspalace.tusky.adapter.TabAdapter
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.getDimension
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
|
@ -272,7 +274,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
private fun showSelectListDialog() {
|
||||
val adapter = ListSelectionAdapter(this)
|
||||
val adapter = object : ArrayAdapter<MastoList>(this, android.R.layout.simple_list_item_1) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
getItem(position)?.let { item -> (view as TextView).text = item.title }
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
val statusLayout = LinearLayout(this)
|
||||
statusLayout.gravity = Gravity.CENTER
|
||||
|
@ -298,12 +306,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(statusLayout)
|
||||
.setAdapter(adapter) { _, position ->
|
||||
val list = adapter.getItem(position)
|
||||
val newTab = createTabDataFromId(LIST, listOf(list!!.id, list.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
adapter.getItem(position)?.let { item ->
|
||||
val newTab = createTabDataFromId(LIST, listOf(item.id, item.title))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
}
|
||||
|
||||
val showProgressBarJob = getProgressBarJob(progress, 500)
|
||||
|
|
|
@ -18,15 +18,20 @@ package com.keylesspalace.tusky
|
|||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.SCHEMA_VERSION
|
||||
import com.keylesspalace.tusky.util.APP_THEME_DEFAULT
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.setAppNightMode
|
||||
import com.keylesspalace.tusky.worker.PruneCacheWorker
|
||||
import com.keylesspalace.tusky.worker.WorkerFactory
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
|
@ -35,6 +40,7 @@ import de.c1710.filemojicompat_ui.helpers.EmojiPreference
|
|||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
class TuskyApplication : Application(), HasAndroidInjector {
|
||||
|
@ -42,7 +48,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
lateinit var androidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var notificationWorkerFactory: NotificationWorkerFactory
|
||||
lateinit var workerFactory: WorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var localeManager: LocaleManager
|
||||
|
@ -90,12 +96,24 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
Log.w("RxJava", "undeliverable exception", it)
|
||||
}
|
||||
|
||||
NotificationHelper.createWorkerNotificationChannel(this)
|
||||
|
||||
WorkManager.initialize(
|
||||
this,
|
||||
androidx.work.Configuration.Builder()
|
||||
.setWorkerFactory(notificationWorkerFactory)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Prune the database every ~ 12 hours when the device is idle.
|
||||
val pruneCacheWorker = PeriodicWorkRequestBuilder<PruneCacheWorker>(12, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build())
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
PruneCacheWorker.PERIODIC_WORK_TAG,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
pruneCacheWorker
|
||||
)
|
||||
}
|
||||
|
||||
override fun androidInjector() = androidInjector
|
||||
|
|
|
@ -39,6 +39,7 @@ import android.webkit.MimeTypeMap
|
|||
import android.widget.Toast
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
@ -96,7 +97,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
|
|||
supportPostponeEnterTransition()
|
||||
|
||||
// Gather the parameters.
|
||||
attachments = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENTS)
|
||||
attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
|
||||
val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)
|
||||
|
||||
// Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener
|
||||
|
|
|
@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.keylesspalace.tusky.databinding.ItemEditFieldBinding
|
||||
import com.keylesspalace.tusky.entity.StringField
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.fixTextSelection
|
||||
|
||||
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
|
||||
|
||||
|
@ -81,12 +82,16 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
|
|||
}
|
||||
|
||||
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
|
||||
fieldData[holder.bindingAdapterPosition].first = newText.toString()
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
|
||||
}
|
||||
|
||||
holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
|
||||
fieldData[holder.bindingAdapterPosition].second = newText.toString()
|
||||
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
|
||||
}
|
||||
|
||||
// Ensure the textview contents are selectable
|
||||
holder.binding.accountFieldNameText.fixTextSelection()
|
||||
holder.binding.accountFieldValueText.fixTextSelection()
|
||||
}
|
||||
|
||||
class MutableStringPair(var first: String, var second: String)
|
||||
|
|
|
@ -25,9 +25,14 @@ import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapt
|
|||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
@ -35,6 +40,7 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|||
class FollowRequestViewHolder(
|
||||
private val binding: ItemFollowRequestBinding,
|
||||
private val accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
private val showHeader: Boolean
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
|
@ -87,9 +93,16 @@ class FollowRequestViewHolder(
|
|||
binding.notificationTextView.visible(showHeader)
|
||||
val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username)
|
||||
binding.usernameTextView.text = formattedUsername
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_48dp
|
||||
)
|
||||
if (account.note.isEmpty()) {
|
||||
binding.accountNote.hide()
|
||||
} else {
|
||||
binding.accountNote.show()
|
||||
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml()
|
||||
.emojify(account.emojis, binding.accountNote, animateEmojis)
|
||||
setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar)
|
||||
binding.avatarBadge.visible(showBotOverlay && account.bot)
|
||||
}
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
/* Copyright 2019 kyori19
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemPickerListBinding
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
|
||||
class ListSelectionAdapter(context: Context) : ArrayAdapter<MastoList>(context, R.layout.item_picker_list) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val binding = if (convertView == null) {
|
||||
ItemPickerListBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
} else {
|
||||
ItemPickerListBinding.bind(convertView)
|
||||
}
|
||||
|
||||
getItem(position)?.let { list ->
|
||||
binding.root.text = list.title
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
}
|
|
@ -397,7 +397,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (replyCountLabel == null) return;
|
||||
|
||||
if (fullStats) {
|
||||
replyCountLabel.setText(NumberUtils.shortNumber(repliesCount));
|
||||
replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -114,11 +114,11 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
protected void setReblogsCount(int reblogsCount) {
|
||||
reblogsCountLabel.setText(NumberUtils.shortNumber(reblogsCount));
|
||||
reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000));
|
||||
}
|
||||
|
||||
protected void setFavouritedCount(int favouritedCount) {
|
||||
favouritedCountLabel.setText(NumberUtils.shortNumber(favouritedCount));
|
||||
favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000));
|
||||
}
|
||||
|
||||
protected void hideStatusInfo() {
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||
import com.keylesspalace.tusky.entity.TrendingTagHistory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
import java.text.NumberFormat
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
|
||||
class TrendingTagViewHolder(
|
||||
private val binding: ItemTrendingCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setup(
|
||||
tagViewData: TrendingViewData.Tag,
|
||||
maxTrendingValue: Long,
|
||||
trendingListener: LinkListener
|
||||
) {
|
||||
val reversedHistory = tagViewData.tag.history.reversed()
|
||||
setGraph(reversedHistory, maxTrendingValue)
|
||||
setTag(tagViewData.tag.name)
|
||||
|
||||
val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
|
||||
binding.totalUsage.text = formatNumber(totalUsage)
|
||||
|
||||
val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
|
||||
binding.totalAccounts.text = formatNumber(totalAccounts)
|
||||
|
||||
binding.currentUsage.text = reversedHistory.last().uses
|
||||
binding.currentAccounts.text = reversedHistory.last().accounts
|
||||
|
||||
itemView.setOnClickListener {
|
||||
trendingListener.onViewTag(tagViewData.tag.name)
|
||||
}
|
||||
|
||||
setAccessibility(totalAccounts, tagViewData.tag.name)
|
||||
}
|
||||
|
||||
private fun setGraph(history: List<TrendingTagHistory>, maxTrendingValue: Long) {
|
||||
binding.graph.maxTrendingValue = maxTrendingValue
|
||||
binding.graph.primaryLineData = history
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
binding.graph.secondaryLineData = history
|
||||
.mapNotNull { it.accounts.toLongOrNull() }
|
||||
}
|
||||
|
||||
private fun setTag(tag: String) {
|
||||
binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
|
||||
}
|
||||
|
||||
private fun setAccessibility(totalAccounts: Long, tag: String) {
|
||||
itemView.contentDescription =
|
||||
itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
|
||||
private val ln_1k = ln(1000.0)
|
||||
|
||||
/**
|
||||
* Format numbers according to the current locale. Numbers < min have
|
||||
* separators (',', '.', etc) inserted according to the locale.
|
||||
*
|
||||
* Numbers > min are scaled down to that by multiples of 1,000, and
|
||||
* a suffix appropriate to the scaling is appended.
|
||||
*/
|
||||
private fun formatNumber(num: Long, min: Int = 100000): String {
|
||||
if (num < min) return numberFormatter.format(num)
|
||||
|
||||
val exp = (ln(num.toDouble()) / ln_1k).toInt()
|
||||
|
||||
// TODO: is the choice of suffixes here locale-agnostic?
|
||||
return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
|
||||
}
|
||||
}
|
||||
}
|
|
@ -40,7 +40,6 @@ import com.keylesspalace.tusky.util.viewBinding
|
|||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ListsForAccountFragment : DialogFragment(), Injectable {
|
||||
|
@ -103,16 +102,7 @@ class ListsForAccountFragment : DialogFragment(), Injectable {
|
|||
binding.listsView.hide()
|
||||
binding.messageView.apply {
|
||||
show()
|
||||
|
||||
if (error is IOException) {
|
||||
setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
load()
|
||||
}
|
||||
} else {
|
||||
setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
load()
|
||||
}
|
||||
}
|
||||
setup(error) { load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,6 @@ import com.mikepenz.iconics.utils.colorInt
|
|||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
@ -133,12 +132,7 @@ class AccountMediaFragment :
|
|||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
|
||||
}
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error)
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
|
|
|
@ -19,14 +19,14 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ActivityAccountListBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListActivity : BaseActivity(), HasAndroidInjector {
|
||||
class AccountListActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
|
|
@ -32,7 +32,10 @@ import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
|
|||
import autodispose2.autoDispose
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type
|
||||
import com.keylesspalace.tusky.components.accountlist.adapter.AccountAdapter
|
||||
|
@ -47,6 +50,7 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.HttpHeaderLink
|
||||
|
@ -60,7 +64,11 @@ import retrofit2.Response
|
|||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
|
||||
class AccountListFragment :
|
||||
Fragment(R.layout.fragment_account_list),
|
||||
AccountActionListener,
|
||||
LinkListener,
|
||||
Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
@ -107,7 +115,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
instanceName = accountManager.activeAccount!!.domain,
|
||||
accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true
|
||||
)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay)
|
||||
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter)
|
||||
followRequestsAdapter
|
||||
}
|
||||
|
@ -131,6 +139,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
fetchAccounts()
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
(activity as BaseActivity?)
|
||||
?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
(activity as BaseActivity?)?.let {
|
||||
val intent = AccountActivity.getIntent(it, id)
|
||||
|
@ -138,6 +151,10 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
}
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
(activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
|
||||
}
|
||||
|
||||
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
|
@ -376,16 +393,9 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
|
|||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
if (throwable is IOException) {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
} else {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchAccounts(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,10 +20,12 @@ import android.view.ViewGroup
|
|||
import com.keylesspalace.tusky.adapter.FollowRequestViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding
|
||||
import com.keylesspalace.tusky.interfaces.AccountActionListener
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
||||
/** Displays a list of follow requests with accept/reject buttons. */
|
||||
class FollowRequestsAdapter(
|
||||
accountActionListener: AccountActionListener,
|
||||
private val linkListener: LinkListener,
|
||||
animateAvatar: Boolean,
|
||||
animateEmojis: Boolean,
|
||||
showBotOverlay: Boolean
|
||||
|
@ -43,6 +45,7 @@ class FollowRequestsAdapter(
|
|||
return FollowRequestViewHolder(
|
||||
binding,
|
||||
accountActionListener,
|
||||
linkListener,
|
||||
showHeader = false
|
||||
)
|
||||
}
|
||||
|
|
|
@ -53,7 +53,9 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.view.ContentInfoCompat
|
||||
import androidx.core.view.OnReceiveContentListener
|
||||
import androidx.core.view.isGone
|
||||
|
@ -76,6 +78,7 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.adapter.EmojiAdapter
|
||||
import com.keylesspalace.tusky.adapter.LocaleAdapter
|
||||
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind
|
||||
import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
|
@ -142,6 +145,9 @@ class ComposeActivity :
|
|||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
/** The account that is being used to compose the status */
|
||||
private lateinit var activeAccount: AccountEntity
|
||||
|
||||
private var photoUploadUri: Uri? = null
|
||||
|
||||
private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
|
@ -208,10 +214,15 @@ class ComposeActivity :
|
|||
notificationManager.cancel(notificationId)
|
||||
}
|
||||
|
||||
val accountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
if (accountId != -1L) {
|
||||
accountManager.setActiveAccount(accountId)
|
||||
}
|
||||
// If started from an intent then compose as the account ID from the intent.
|
||||
// Otherwise use the active account. If null then the user is not logged in,
|
||||
// and return from the activity.
|
||||
val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1)
|
||||
activeAccount = if (intentAccountId != -1L) {
|
||||
accountManager.getAccountById(intentAccountId)
|
||||
} else {
|
||||
accountManager.activeAccount
|
||||
} ?: return
|
||||
|
||||
val theme = preferences.getString("appTheme", APP_THEME_DEFAULT)
|
||||
if (theme == "black") {
|
||||
|
@ -220,8 +231,6 @@ class ComposeActivity :
|
|||
setContentView(binding.root)
|
||||
|
||||
setupActionBar()
|
||||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
|
@ -245,7 +254,7 @@ class ComposeActivity :
|
|||
|
||||
/* If the composer is started up as a reply to another post, override the "starting" state
|
||||
* based on what the intent from the reply request passes. */
|
||||
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
|
||||
val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java)
|
||||
viewModel.setup(composeOptions)
|
||||
|
||||
setupButtons()
|
||||
|
@ -279,7 +288,7 @@ class ComposeActivity :
|
|||
|
||||
/* Finally, overwrite state with data from saved instance state. */
|
||||
savedInstanceState?.let {
|
||||
photoUploadUri = it.getParcelable(PHOTO_UPLOAD_URI_KEY)
|
||||
photoUploadUri = BundleCompat.getParcelable(it, PHOTO_UPLOAD_URI_KEY, Uri::class.java)
|
||||
|
||||
(it.getSerializable(VISIBILITY_KEY) as Status.Visibility).apply {
|
||||
setStatusVisibility(this)
|
||||
|
@ -308,12 +317,12 @@ class ComposeActivity :
|
|||
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
|
||||
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri ->
|
||||
pickMedia(uri)
|
||||
}
|
||||
}
|
||||
|
@ -934,7 +943,10 @@ class ComposeActivity :
|
|||
val split = contentInfo.partition { item: ClipData.Item -> item.uri != null }
|
||||
split.first?.let { content ->
|
||||
for (i in 0 until content.clip.itemCount) {
|
||||
pickMedia(content.clip.getItemAt(i).uri)
|
||||
pickMedia(
|
||||
content.clip.getItemAt(i).uri,
|
||||
contentInfo.clip.description.label as String?
|
||||
)
|
||||
}
|
||||
}
|
||||
return split.second
|
||||
|
@ -955,7 +967,7 @@ class ComposeActivity :
|
|||
enableButtons(true, viewModel.editing)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.sendStatus(contentText, spoilerText)
|
||||
viewModel.sendStatus(contentText, spoilerText, activeAccount.id)
|
||||
deleteDraftAndFinish()
|
||||
}
|
||||
} else {
|
||||
|
@ -1055,9 +1067,9 @@ class ComposeActivity :
|
|||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
private fun pickMedia(uri: Uri, description: String? = null) {
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri).onFailure { throwable ->
|
||||
viewModel.pickMedia(uri, description).onFailure { throwable ->
|
||||
val errorString = when (throwable) {
|
||||
is FileSizeException -> {
|
||||
val decimalFormat = DecimalFormat("0.##")
|
||||
|
@ -1118,16 +1130,19 @@ class ComposeActivity :
|
|||
private fun handleCloseButton() {
|
||||
val contentText = binding.composeEditField.text.toString()
|
||||
val contentWarning = binding.composeContentWarningField.text.toString()
|
||||
if (viewModel.didChange(contentText, contentWarning)) {
|
||||
when (viewModel.composeKind) {
|
||||
ComposeKind.NEW -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_DRAFT -> getUpdateDraftOrDiscardDialog(contentText, contentWarning)
|
||||
ComposeKind.EDIT_POSTED -> getContinueEditingOrDiscardDialog()
|
||||
ComposeKind.EDIT_SCHEDULED -> getContinueEditingOrDiscardDialog()
|
||||
}.show()
|
||||
} else {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
when (viewModel.handleCloseButton(contentText, contentWarning)) {
|
||||
ConfirmationKind.NONE -> {
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
ConfirmationKind.SAVE_OR_DISCARD ->
|
||||
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
ConfirmationKind.UPDATE_OR_DISCARD ->
|
||||
getUpdateDraftOrDiscardDialog(contentText, contentWarning).show()
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ->
|
||||
getContinueEditingOrDiscardDialog().show()
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ->
|
||||
getDeleteEmptyDraftOrContinueEditing().show()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1192,6 +1207,23 @@ class ComposeActivity :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User is editing an existing draft and making it empty.
|
||||
* The user can either delete the empty draft or go back to editing.
|
||||
*/
|
||||
private fun getDeleteEmptyDraftOrContinueEditing(): AlertDialog.Builder {
|
||||
return AlertDialog.Builder(this)
|
||||
.setMessage(R.string.compose_delete_draft)
|
||||
.setPositiveButton(R.string.action_delete) { _, _ ->
|
||||
viewModel.deleteDraft()
|
||||
viewModel.stopUploads()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
.setNegativeButton(R.string.action_continue_edit) { _, _ ->
|
||||
// Do nothing, dialog will dismiss, user can continue editing
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
|
@ -94,7 +95,7 @@ class ComposeViewModel @Inject constructor(
|
|||
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
|
||||
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
lateinit var composeKind: ComposeActivity.ComposeKind
|
||||
lateinit var composeKind: ComposeKind
|
||||
|
||||
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
|
||||
var cropImageItemOld: QueuedMedia? = null
|
||||
|
@ -213,7 +214,28 @@ class ComposeViewModel @Inject constructor(
|
|||
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind {
|
||||
return if (didChange(contentText, contentWarning)) {
|
||||
when (composeKind) {
|
||||
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) {
|
||||
ConfirmationKind.NONE
|
||||
} else {
|
||||
ConfirmationKind.SAVE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) {
|
||||
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
|
||||
} else {
|
||||
ConfirmationKind.UPDATE_OR_DISCARD
|
||||
}
|
||||
ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
|
||||
ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES
|
||||
}
|
||||
} else {
|
||||
ConfirmationKind.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
val textChanged = content.orEmpty() != startingText.orEmpty()
|
||||
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning
|
||||
val mediaChanged = media.value.isNotEmpty()
|
||||
|
@ -223,6 +245,10 @@ class ComposeViewModel @Inject constructor(
|
|||
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange
|
||||
}
|
||||
|
||||
private fun isEmpty(content: String?, contentWarning: String?): Boolean {
|
||||
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null)
|
||||
}
|
||||
|
||||
fun contentWarningChanged(value: Boolean) {
|
||||
showContentWarning.value = value
|
||||
contentWarningStateChanged = true
|
||||
|
@ -283,7 +309,8 @@ class ComposeViewModel @Inject constructor(
|
|||
*/
|
||||
suspend fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
spoilerText: String,
|
||||
accountId: Long
|
||||
) {
|
||||
if (!scheduledTootId.isNullOrEmpty()) {
|
||||
api.deleteScheduledStatus(scheduledTootId!!)
|
||||
|
@ -310,7 +337,7 @@ class ComposeViewModel @Inject constructor(
|
|||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
accountId = accountId,
|
||||
draftId = draftId,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0,
|
||||
|
@ -389,7 +416,7 @@ class ComposeViewModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
composeKind = composeOptions?.kind ?: ComposeActivity.ComposeKind.NEW
|
||||
composeKind = composeOptions?.kind ?: ComposeKind.NEW
|
||||
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
|
@ -485,6 +512,14 @@ class ComposeViewModel @Inject constructor(
|
|||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
enum class ConfirmationKind {
|
||||
NONE, // just close
|
||||
SAVE_OR_DISCARD,
|
||||
UPDATE_OR_DISCARD,
|
||||
CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post
|
||||
CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -27,6 +27,7 @@ import android.view.ViewGroup
|
|||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -73,7 +74,7 @@ class CaptionDialog : DialogFragment() {
|
|||
val window = dialog.window
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
val previewUri = arguments?.getParcelable<Uri>(PREVIEW_URI_ARG) ?: error("Preview Uri is null")
|
||||
val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null")
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed size.
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
|
|
|
@ -15,13 +15,13 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -38,7 +38,7 @@ fun <T> T.makeFocusDialog(
|
|||
existingFocus: Focus?,
|
||||
previewUri: Uri,
|
||||
onUpdateFocus: suspend (Focus) -> Unit
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
) where T : AppCompatActivity, T : LifecycleOwner {
|
||||
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
|
||||
|
||||
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)
|
||||
|
|
|
@ -89,7 +89,7 @@ data class ConversationStatusEntity(
|
|||
val bookmarked: Boolean,
|
||||
val sensitive: Boolean,
|
||||
val spoilerText: String,
|
||||
val attachments: ArrayList<Attachment>,
|
||||
val attachments: List<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val tags: List<HashTag>?,
|
||||
val showingHiddenContent: Boolean,
|
||||
|
|
|
@ -64,7 +64,6 @@ import com.mikepenz.iconics.utils.sizeDp
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
@ -139,16 +138,7 @@ class ConversationsFragment :
|
|||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
refreshContent()
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
refreshContent()
|
||||
}
|
||||
}
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() }
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
|
|
|
@ -7,9 +7,11 @@ import android.widget.AdapterView
|
|||
import android.widget.ArrayAdapter
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.view.size
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
|
@ -23,7 +25,9 @@ import com.keylesspalace.tusky.entity.Filter
|
|||
import com.keylesspalace.tusky.entity.FilterKeyword
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -47,7 +51,7 @@ class EditFilterActivity : BaseActivity() {
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
originalFilter = intent?.getParcelableExtra(FILTER_TO_EDIT)
|
||||
originalFilter = IntentCompat.getParcelableExtra(intent, FILTER_TO_EDIT, Filter::class.java)
|
||||
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN.action, listOf())
|
||||
binding.apply {
|
||||
contextSwitches = mapOf(
|
||||
|
@ -77,6 +81,9 @@ class EditFilterActivity : BaseActivity() {
|
|||
|
||||
binding.actionChip.setOnClickListener { showAddKeywordDialog() }
|
||||
binding.filterSaveButton.setOnClickListener { saveChanges() }
|
||||
binding.filterDeleteButton.setOnClickListener { deleteFilter() }
|
||||
binding.filterDeleteButton.visible(originalFilter != null)
|
||||
|
||||
for (switch in contextSwitches.keys) {
|
||||
switch.setOnCheckedChangeListener { _, isChecked ->
|
||||
val context = contextSwitches[switch]!!
|
||||
|
@ -258,6 +265,32 @@ class EditFilterActivity : BaseActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun deleteFilter() {
|
||||
originalFilter?.let { filter ->
|
||||
lifecycleScope.launch {
|
||||
api.deleteFilter(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{ throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
api.deleteFilterV1(filter.id).fold(
|
||||
{
|
||||
finish()
|
||||
},
|
||||
{
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILTER_TO_EDIT = "FilterToEdit"
|
||||
|
||||
|
|
|
@ -82,7 +82,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
} else {
|
||||
binding.messageView.hide()
|
||||
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
|
||||
binding.filtersList.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -91,7 +90,6 @@ class FiltersActivity : BaseActivity(), FiltersListener {
|
|||
}
|
||||
|
||||
private fun loadFilters() {
|
||||
binding.filtersList.hide()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,6 @@ import com.keylesspalace.tusky.util.viewBinding
|
|||
import com.keylesspalace.tusky.util.visible
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class FollowedTagsActivity :
|
||||
|
@ -108,11 +107,7 @@ class FollowedTagsActivity :
|
|||
binding.followedTagsView.hide()
|
||||
binding.followedTagsMessageView.show()
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
if (errorState.error is IOException) {
|
||||
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
|
||||
} else {
|
||||
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
|
||||
}
|
||||
binding.followedTagsMessageView.setup(errorState.error) { retry() }
|
||||
Log.w(TAG, "error loading followed hashtags", errorState.error)
|
||||
} else {
|
||||
binding.followedTagsView.show()
|
||||
|
|
|
@ -26,7 +26,6 @@ import com.keylesspalace.tusky.util.viewBinding
|
|||
import com.keylesspalace.tusky.view.EndlessOnScrollListener
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
|
||||
|
@ -146,16 +145,9 @@ class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectab
|
|||
|
||||
if (adapter.itemCount == 0) {
|
||||
binding.messageView.show()
|
||||
if (throwable is IOException) {
|
||||
binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
} else {
|
||||
binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
binding.messageView.setup(throwable) {
|
||||
binding.messageView.hide()
|
||||
this.fetchInstances(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import android.webkit.WebViewClient
|
|||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
|
@ -61,7 +62,9 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
|||
return if (resultCode == Activity.RESULT_CANCELED) {
|
||||
LoginResult.Cancel
|
||||
} else {
|
||||
intent!!.getParcelableExtra(RESULT_EXTRA)!!
|
||||
intent?.let {
|
||||
IntentCompat.getParcelableExtra(it, RESULT_EXTRA, LoginResult::class.java)
|
||||
} ?: LoginResult.Err("failed parsing LoginWebViewActivity result")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,7 +73,7 @@ class OauthLogin : ActivityResultContract<LoginData, LoginResult>() {
|
|||
private const val DATA_EXTRA = "data"
|
||||
|
||||
fun parseData(intent: Intent): LoginData {
|
||||
return intent.getParcelableExtra(DATA_EXTRA)!!
|
||||
return IntentCompat.getParcelableExtra(intent, DATA_EXTRA, LoginData::class.java)!!
|
||||
}
|
||||
|
||||
fun makeResultIntent(result: LoginResult): Intent {
|
||||
|
|
|
@ -22,16 +22,19 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.databinding.ItemFollowBinding
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
|
||||
class FollowViewHolder(
|
||||
private val binding: ItemFollowBinding,
|
||||
private val notificationActionListener: NotificationActionListener
|
||||
private val notificationActionListener: NotificationActionListener,
|
||||
private val linkListener: LinkListener
|
||||
) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) {
|
||||
private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize(
|
||||
R.dimen.avatar_radius_42dp
|
||||
|
@ -94,11 +97,12 @@ class FollowViewHolder(
|
|||
animateAvatars
|
||||
)
|
||||
|
||||
binding.notificationAccountNote.text = account.note.parseAsMastodonHtml().emojify(
|
||||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(
|
||||
account.emojis,
|
||||
binding.notificationAccountNote,
|
||||
animateEmojis
|
||||
)
|
||||
setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener)
|
||||
}
|
||||
|
||||
private fun setupButtons(listener: NotificationActionListener, accountId: String) {
|
||||
|
|
|
@ -1,75 +1,190 @@
|
|||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
import com.keylesspalace.tusky.entity.Notification
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.isLessThan
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Fetch Mastodon notifications and show Android notifications, with summaries, for them.
|
||||
*
|
||||
* Should only be called by a worker thread.
|
||||
*
|
||||
* @see NotificationWorker
|
||||
* @see <a href="https://developer.android.com/guide/background/persistent/threading/worker">Background worker</a>
|
||||
*/
|
||||
@WorkerThread
|
||||
class NotificationFetcher @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val context: Context
|
||||
) {
|
||||
fun fetchAndShow() {
|
||||
suspend fun fetchAndShow() {
|
||||
for (account in accountManager.getAllAccountsOrderedByActive()) {
|
||||
if (account.notificationsEnabled) {
|
||||
try {
|
||||
val notifications = fetchNotifications(account)
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
NotificationHelper.make(context, notification, account, index == 0)
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Create sorted list of new notifications
|
||||
val notifications = fetchNewNotifications(account)
|
||||
.filter { filterNotification(notificationManager, account, it) }
|
||||
.sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first
|
||||
.toMutableList()
|
||||
|
||||
// There's a maximum limit on the number of notifications an Android app
|
||||
// can display. If the total number of notifications (current notifications,
|
||||
// plus new ones) exceeds this then some newer notifications will be dropped.
|
||||
//
|
||||
// Err on the side of removing *older* notifications to make room for newer
|
||||
// notifications.
|
||||
val currentAndroidNotifications = notificationManager.activeNotifications
|
||||
.sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first
|
||||
|
||||
// Check to see if any notifications need to be removed
|
||||
val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS
|
||||
if (toRemove > 0) {
|
||||
// Prefer to cancel old notifications first
|
||||
currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size))
|
||||
.forEach { notificationManager.cancel(it.tag, it.id) }
|
||||
|
||||
// Still got notifications to remove? Trim the list of new notifications,
|
||||
// starting with the oldest.
|
||||
while (notifications.size > MAX_NOTIFICATIONS) {
|
||||
notifications.removeAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Make and send the new notifications
|
||||
// TODO: Use the batch notification API available in NotificationManagerCompat
|
||||
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
|
||||
// when it is released.
|
||||
notifications.forEachIndexed { index, notification ->
|
||||
val androidNotification = NotificationHelper.make(
|
||||
context,
|
||||
notificationManager,
|
||||
notification,
|
||||
account,
|
||||
index == 0
|
||||
)
|
||||
notificationManager.notify(notification.id, account.id.toInt(), androidNotification)
|
||||
// Android will rate limit / drop notifications if they're posted too
|
||||
// quickly. There is no indication to the user that this happened.
|
||||
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
|
||||
delay(1000.milliseconds)
|
||||
}
|
||||
|
||||
NotificationHelper.updateSummaryNotifications(
|
||||
context,
|
||||
notificationManager,
|
||||
account
|
||||
)
|
||||
|
||||
accountManager.saveAccount(account)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error while fetching notifications", e)
|
||||
Log.e(TAG, "Error while fetching notifications", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchNotifications(account: AccountEntity): MutableList<Notification> {
|
||||
/**
|
||||
* Fetch new Mastodon Notifications and update the marker position.
|
||||
*
|
||||
* Here, "new" means "notifications with IDs newer than notifications the user has already
|
||||
* seen."
|
||||
*
|
||||
* The "water mark" for Mastodon Notification IDs are stored in three places.
|
||||
*
|
||||
* - acccount.lastNotificationId -- the ID of the top-most notification when the user last
|
||||
* left the Notifications tab.
|
||||
* - The Mastodon "marker" API -- the ID of the most recent notification fetched here.
|
||||
* - account.notificationMarkerId -- local version of the value from the Mastodon marker
|
||||
* API, in case the Mastodon server does not implement that API.
|
||||
*
|
||||
* The user may have refreshed the "Notifications" tab and seen notifications newer than the
|
||||
* ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater
|
||||
* than the marker.
|
||||
*/
|
||||
private suspend fun fetchNewNotifications(account: AccountEntity): List<Notification> {
|
||||
val authHeader = String.format("Bearer %s", account.accessToken)
|
||||
// We fetch marker to not load/show notifications which user has already seen
|
||||
val marker = fetchMarker(authHeader, account)
|
||||
if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) {
|
||||
account.lastNotificationId = marker.lastReadId
|
||||
}
|
||||
Log.d(TAG, "getting Notifications for " + account.fullName)
|
||||
val notifications = mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
account.lastNotificationId
|
||||
).blockingGet()
|
||||
|
||||
val newId = account.lastNotificationId
|
||||
var newestId = ""
|
||||
val result = mutableListOf<Notification>()
|
||||
for (notification in notifications.reversed()) {
|
||||
val currentId = notification.id
|
||||
if (newestId.isLessThan(currentId)) {
|
||||
newestId = currentId
|
||||
account.lastNotificationId = currentId
|
||||
}
|
||||
if (newId.isLessThan(currentId)) {
|
||||
result.add(notification)
|
||||
// Figure out where to read from. Choose the most recent notification ID from:
|
||||
//
|
||||
// - The Mastodon marker API (if the server supports it)
|
||||
// - account.notificationMarkerId
|
||||
// - account.lastNotificationId
|
||||
Log.d(TAG, "getting notification marker for ${account.fullName}")
|
||||
val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0"
|
||||
val localMarkerId = account.notificationMarkerId
|
||||
val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId
|
||||
val readingPosition = account.lastNotificationId
|
||||
|
||||
var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition
|
||||
Log.d(TAG, " remoteMarkerId: $remoteMarkerId")
|
||||
Log.d(TAG, " localMarkerId: $localMarkerId")
|
||||
Log.d(TAG, " readingPosition: $readingPosition")
|
||||
|
||||
Log.d(TAG, "getting Notifications for ${account.fullName}, min_id: $minId")
|
||||
|
||||
// Fetch all outstanding notifications
|
||||
val notifications = buildList {
|
||||
while (minId != null) {
|
||||
val response = mastodonApi.notificationsWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
minId = minId
|
||||
)
|
||||
if (!response.isSuccessful) break
|
||||
|
||||
// Notifications are returned in the page in order, newest first,
|
||||
// (https://github.com/mastodon/documentation/issues/1226), insert the
|
||||
// new page at the head of the list.
|
||||
response.body()?.let { addAll(0, it) }
|
||||
|
||||
// Get the previous page, which will be chronologically newer
|
||||
// notifications. If it doesn't exist this is null and the loop
|
||||
// will exit.
|
||||
val links = Links.from(response.headers()["link"])
|
||||
minId = links.prev
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
// Save the newest notification ID in the marker.
|
||||
notifications.firstOrNull()?.let {
|
||||
val newMarkerId = notifications.first().id
|
||||
Log.d(TAG, "updating notification marker for ${account.fullName} to: $newMarkerId")
|
||||
mastodonApi.updateMarkersWithAuth(
|
||||
auth = authHeader,
|
||||
domain = account.domain,
|
||||
notificationsLastReadId = newMarkerId
|
||||
)
|
||||
account.notificationMarkerId = newMarkerId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
|
||||
return notifications
|
||||
}
|
||||
|
||||
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
|
||||
private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
|
||||
return try {
|
||||
val allMarkers = mastodonApi.markersWithAuth(
|
||||
authHeader,
|
||||
account.domain,
|
||||
listOf("notifications")
|
||||
).blockingGet()
|
||||
)
|
||||
val notificationMarker = allMarkers["notifications"]
|
||||
Log.d(TAG, "Fetched marker: $notificationMarker")
|
||||
Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker")
|
||||
notificationMarker
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch marker", e)
|
||||
|
@ -78,6 +193,13 @@ class NotificationFetcher @Inject constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "NotificationFetcher"
|
||||
private const val TAG = "NotificationFetcher"
|
||||
|
||||
// There's a system limit on the maximum number of notifications an app
|
||||
// can show, NotificationManagerService.MAX_PACKAGE_NOTIFICATIONS. Unfortunately
|
||||
// that's not available to client code or via the NotificationManager API.
|
||||
// The current value in the Android source code is 50, set 40 here to both
|
||||
// be conservative, and allow some headroom for summary notifications.
|
||||
private const val MAX_NOTIFICATIONS = 40
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.notifications;
|
||||
|
||||
import static com.keylesspalace.tusky.BuildConfig.APPLICATION_ID;
|
||||
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
||||
|
@ -28,18 +29,22 @@ import android.content.Intent;
|
|||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
import androidx.core.app.TaskStackBuilder;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.OutOfQuotaPolicy;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.WorkRequest;
|
||||
|
@ -56,35 +61,36 @@ import com.keylesspalace.tusky.entity.Notification;
|
|||
import com.keylesspalace.tusky.entity.Poll;
|
||||
import com.keylesspalace.tusky.entity.PollOption;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class NotificationHelper {
|
||||
|
||||
private static int notificationId = 0;
|
||||
/** ID of notification shown when fetching notifications */
|
||||
public static final int NOTIFICATION_ID_FETCH_NOTIFICATION = 0;
|
||||
/** ID of notification shown when pruning the cache */
|
||||
public static final int NOTIFICATION_ID_PRUNE_CACHE = 1;
|
||||
/** Dynamic notification IDs start here */
|
||||
private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1;
|
||||
|
||||
/**
|
||||
* constants used in Intents
|
||||
*/
|
||||
public static final String ACCOUNT_ID = "account_id";
|
||||
|
||||
public static final String TYPE = "type";
|
||||
public static final String TYPE = APPLICATION_ID + ".notification.type";
|
||||
|
||||
private static final String TAG = "NotificationHelper";
|
||||
|
||||
|
@ -121,57 +127,60 @@ public class NotificationHelper {
|
|||
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
|
||||
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
|
||||
public static final String CHANNEL_REPORT = "CHANNEL_REPORT";
|
||||
public static final String CHANNEL_BACKGROUND_TASKS = "CHANNEL_BACKGROUND_TASKS";
|
||||
|
||||
/**
|
||||
* WorkManager Tag
|
||||
*/
|
||||
private static final String NOTIFICATION_PULL_TAG = "pullNotifications";
|
||||
|
||||
/** Tag for the summary notification */
|
||||
private static final String GROUP_SUMMARY_TAG = APPLICATION_ID + ".notification.group_summary";
|
||||
|
||||
/** The name of the account that caused the notification, for use in a summary */
|
||||
private static final String EXTRA_ACCOUNT_NAME = APPLICATION_ID + ".notification.extra.account_name";
|
||||
|
||||
/** The notification's type (string representation of a Notification.Type) */
|
||||
private static final String EXTRA_NOTIFICATION_TYPE = APPLICATION_ID + ".notification.extra.notification_type";
|
||||
|
||||
/**
|
||||
* Takes a given Mastodon notification and either creates a new Android notification or updates
|
||||
* the state of the existing notification to reflect the new interaction.
|
||||
* Takes a given Mastodon notification and creates a new Android notification or updates the
|
||||
* existing Android notification.
|
||||
* <p>
|
||||
* The Android notification has it's tag set to the Mastodon notification ID, and it's ID set
|
||||
* to the ID of the account that received the notification.
|
||||
*
|
||||
* @param context to access application preferences and services
|
||||
* @param body a new Mastodon notification
|
||||
* @param account the account for which the notification should be shown
|
||||
* @return the new notification
|
||||
*/
|
||||
|
||||
public static void make(final Context context, Notification body, AccountEntity account, boolean isFirstOfBatch) {
|
||||
@NonNull
|
||||
public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) {
|
||||
body = body.rewriteToStatusTypeIfNeeded(account.getAccountId());
|
||||
String mastodonNotificationId = body.getId();
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
if (!filterNotification(account, body, context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawCurrentNotifications = account.getActiveNotifications();
|
||||
JSONArray currentNotifications;
|
||||
|
||||
try {
|
||||
currentNotifications = new JSONArray(rawCurrentNotifications);
|
||||
} catch (JSONException e) {
|
||||
currentNotifications = new JSONArray();
|
||||
}
|
||||
|
||||
for (int i = 0; i < currentNotifications.length(); i++) {
|
||||
try {
|
||||
if (currentNotifications.getString(i).equals(body.getAccount().getName())) {
|
||||
currentNotifications.remove(i);
|
||||
break;
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
Log.d(TAG, Log.getStackTraceString(e));
|
||||
// Check for an existing notification with this Mastodon Notification ID
|
||||
android.app.Notification existingAndroidNotification = null;
|
||||
StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications();
|
||||
for (StatusBarNotification androidNotification : activeNotifications) {
|
||||
if (mastodonNotificationId.equals(androidNotification.getTag()) && accountId == androidNotification.getId()) {
|
||||
existingAndroidNotification = androidNotification.getNotification();
|
||||
}
|
||||
}
|
||||
|
||||
currentNotifications.put(body.getAccount().getName());
|
||||
|
||||
account.setActiveNotifications(currentNotifications.toString());
|
||||
|
||||
// Notification group member
|
||||
// =========================
|
||||
final NotificationCompat.Builder builder = newNotification(context, body, account, false);
|
||||
|
||||
notificationId++;
|
||||
// Create the notification -- either create a new one, or use the existing one.
|
||||
NotificationCompat.Builder builder;
|
||||
if (existingAndroidNotification == null) {
|
||||
builder = newAndroidNotification(context, body, account);
|
||||
} else {
|
||||
builder = new NotificationCompat.Builder(context, existingAndroidNotification);
|
||||
}
|
||||
|
||||
builder.setContentTitle(titleForType(context, body, account))
|
||||
.setContentText(bodyForType(body, context, account.getAlwaysOpenSpoiler()));
|
||||
|
@ -233,51 +242,136 @@ public class NotificationHelper {
|
|||
builder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
|
||||
// only alert for the first notification of a batch to avoid multiple alerts at once
|
||||
Bundle extras = new Bundle();
|
||||
// Add the sending account's name, so it can be used when summarising this notification
|
||||
extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName());
|
||||
extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString());
|
||||
builder.addExtras(extras);
|
||||
|
||||
// Only alert for the first notification of a batch to avoid multiple alerts at once
|
||||
if(!isFirstOfBatch) {
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY);
|
||||
}
|
||||
|
||||
// Summary
|
||||
final NotificationCompat.Builder summaryBuilder = newNotification(context, body, account, true);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
if (currentNotifications.length() != 1) {
|
||||
try {
|
||||
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, currentNotifications.length(), currentNotifications.length());
|
||||
String text = joinNames(context, currentNotifications);
|
||||
summaryBuilder.setContentTitle(title)
|
||||
.setContentText(text);
|
||||
} catch (JSONException e) {
|
||||
Log.d(TAG, Log.getStackTraceString(e));
|
||||
}
|
||||
/**
|
||||
* Updates the summary notifications for each notification group.
|
||||
* <p>
|
||||
* Notifications are sent to channels. Within each channel they may be grouped, and the group
|
||||
* may have a summary.
|
||||
* <p>
|
||||
* Tusky uses N notification channels for each account, each channel corresponds to a type
|
||||
* of notification (follow, reblog, mention, etc). Therefore each channel also has exactly
|
||||
* 0 or 1 summary notifications along with its regular notifications.
|
||||
* <p>
|
||||
* The group key is the same as the channel ID.
|
||||
* <p>
|
||||
* Regnerates the summary notifications for all active Tusky notifications for `account`.
|
||||
* This may delete the summary notification if there are no active notifications for that
|
||||
* account in a group.
|
||||
*
|
||||
* @see <a href="https://developer.android.com/develop/ui/views/notifications/group">Create a
|
||||
* notification group</a>
|
||||
* @param context to access application preferences and services
|
||||
* @param notificationManager the system's NotificationManager
|
||||
* @param account the account for which the notification should be shown
|
||||
*/
|
||||
public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) {
|
||||
// Map from the channel ID to a list of notifications in that channel. Those are the
|
||||
// notifications that will be summarised.
|
||||
Map<String, List<StatusBarNotification>> channelGroups = new HashMap<>();
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
// Initialise the map with all channel IDs.
|
||||
for (Notification.Type ty : Notification.Type.values()) {
|
||||
channelGroups.put(getChannelId(account, ty), new ArrayList<>());
|
||||
}
|
||||
|
||||
summaryBuilder.setSubText(account.getFullName());
|
||||
summaryBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
|
||||
summaryBuilder.setCategory(NotificationCompat.CATEGORY_SOCIAL);
|
||||
summaryBuilder.setOnlyAlertOnce(true);
|
||||
summaryBuilder.setGroupSummary(true);
|
||||
// Fetch all existing notifications. Add them to the map, ignoring notifications that:
|
||||
// - belong to a different account
|
||||
// - are summary notifications
|
||||
for (StatusBarNotification sn : notificationManager.getActiveNotifications()) {
|
||||
if (sn.getId() != accountId) continue;
|
||||
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
String channelId = sn.getNotification().getGroup();
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
|
||||
if (summaryTag.equals(sn.getTag())) continue;
|
||||
|
||||
notificationManager.notify(notificationId, builder.build());
|
||||
if (currentNotifications.length() == 1) {
|
||||
notificationManager.notify((int) account.getId(), builder.setGroupSummary(true).build());
|
||||
} else {
|
||||
notificationManager.notify((int) account.getId(), summaryBuilder.build());
|
||||
// TODO: API 26 supports getting the channel ID directly (sn.getNotification().getChannelId()).
|
||||
// This works here because the channelId and the groupKey are the same.
|
||||
List<StatusBarNotification> members = channelGroups.get(channelId);
|
||||
if (members == null) { // can't happen, but just in case...
|
||||
Log.e(TAG, "members == null for channel ID " + channelId);
|
||||
continue;
|
||||
}
|
||||
members.add(sn);
|
||||
}
|
||||
|
||||
// Create, update, or cancel the summary notifications for each group.
|
||||
for (Map.Entry<String, List<StatusBarNotification>> channelGroup : channelGroups.entrySet()) {
|
||||
String channelId = channelGroup.getKey();
|
||||
List<StatusBarNotification> members = channelGroup.getValue();
|
||||
String summaryTag = GROUP_SUMMARY_TAG + "." + channelId;
|
||||
|
||||
// If there are 0-1 notifications in this group then the additional summary
|
||||
// notification is not needed and can be cancelled.
|
||||
if (members.size() <= 1) {
|
||||
notificationManager.cancel(summaryTag, accountId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a notification that summarises the other notifications in this group
|
||||
|
||||
// All notifications in this group have the same type, so get it from the first.
|
||||
String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE);
|
||||
|
||||
Intent summaryResultIntent = new Intent(context, MainActivity.class);
|
||||
summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId);
|
||||
summaryResultIntent.putExtra(TYPE, notificationType);
|
||||
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
|
||||
summaryStackBuilder.addParentStack(MainActivity.class);
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent);
|
||||
|
||||
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
String title = context.getResources().getQuantityString(R.plurals.notification_title_summary, members.size(), members.size());
|
||||
String text = joinNames(context, members);
|
||||
|
||||
NotificationCompat.Builder summaryBuilder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summaryResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setDefaults(0) // So it doesn't ring twice, notify only in Target callback
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSubText(account.getFullName())
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setGroup(channelId)
|
||||
.setGroupSummary(true);
|
||||
|
||||
setSoundVibrationLight(account, summaryBuilder);
|
||||
|
||||
// TODO: Use the batch notification API available in NotificationManagerCompat
|
||||
// 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01)
|
||||
// when it is released.
|
||||
notificationManager.notify(summaryTag, accountId, summaryBuilder.build());
|
||||
|
||||
// Android will rate limit / drop notifications if they're posted too
|
||||
// quickly. There is no indication to the user that this happened.
|
||||
// See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664
|
||||
try { Thread.sleep(1000); } catch (InterruptedException ignored) { }
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationCompat.Builder newNotification(Context context, Notification body, AccountEntity account, boolean summary) {
|
||||
Intent summaryResultIntent = new Intent(context, MainActivity.class);
|
||||
summaryResultIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
summaryResultIntent.putExtra(TYPE, body.getType().name());
|
||||
TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context);
|
||||
summaryStackBuilder.addParentStack(MainActivity.class);
|
||||
summaryStackBuilder.addNextIntent(summaryResultIntent);
|
||||
|
||||
PendingIntent summaryResultPendingIntent = summaryStackBuilder.getPendingIntent((int) (notificationId + account.getId() * 10000),
|
||||
pendingIntentFlags(false));
|
||||
private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) {
|
||||
|
||||
// we have to switch account here
|
||||
Intent eventResultIntent = new Intent(context, MainActivity.class);
|
||||
|
@ -290,22 +384,19 @@ public class NotificationHelper {
|
|||
PendingIntent eventResultPendingIntent = eventStackBuilder.getPendingIntent((int) account.getId(),
|
||||
pendingIntentFlags(false));
|
||||
|
||||
Intent deleteIntent = new Intent(context, NotificationClearBroadcastReceiver.class);
|
||||
deleteIntent.putExtra(ACCOUNT_ID, account.getId());
|
||||
PendingIntent deletePendingIntent = PendingIntent.getBroadcast(context, summary ? (int) account.getId() : notificationId, deleteIntent,
|
||||
pendingIntentFlags(false));
|
||||
String channelId = getChannelId(account, body);
|
||||
assert channelId != null;
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, getChannelId(account, body))
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentIntent(summary ? summaryResultPendingIntent : eventResultPendingIntent)
|
||||
.setDeleteIntent(deletePendingIntent)
|
||||
.setContentIntent(eventResultPendingIntent)
|
||||
.setColor(context.getColor(R.color.notification_color))
|
||||
.setGroup(account.getAccountId())
|
||||
.setGroup(channelId)
|
||||
.setAutoCancel(true)
|
||||
.setShortcutId(Long.toString(account.getId()))
|
||||
.setDefaults(0); // So it doesn't ring twice, notify only in Target callback
|
||||
|
||||
setupPreferences(account, builder);
|
||||
setSoundVibrationLight(account, builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
@ -388,6 +479,49 @@ public class NotificationHelper {
|
|||
pendingIntentFlags(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification channel for notifications for background work that should not
|
||||
* disturb the user.
|
||||
*
|
||||
* @param context context
|
||||
*/
|
||||
public static void createWorkerNotificationChannel(@NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_BACKGROUND_TASKS,
|
||||
context.getString(R.string.notification_listenable_worker_name),
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
);
|
||||
|
||||
channel.setDescription(context.getString(R.string.notification_listenable_worker_description));
|
||||
channel.enableLights(false);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification for a background worker.
|
||||
*
|
||||
* @param context context
|
||||
* @param titleResource String resource to use as the notification's title
|
||||
* @return the notification
|
||||
*/
|
||||
@NonNull
|
||||
public static android.app.Notification createWorkerNotification(@NonNull Context context, @StringRes int titleResource) {
|
||||
String title = context.getString(titleResource);
|
||||
return new NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS)
|
||||
.setContentTitle(title)
|
||||
.setTicker(title)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static void createNotificationChannelsForAccount(@NonNull AccountEntity account, @NonNull Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
|
@ -453,7 +587,6 @@ public class NotificationHelper {
|
|||
}
|
||||
|
||||
notificationManager.createNotificationChannels(channels);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -495,6 +628,15 @@ public class NotificationHelper {
|
|||
WorkManager workManager = WorkManager.getInstance(context);
|
||||
workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG);
|
||||
|
||||
// Periodic work requests are supposed to start running soon after being enqueued. In
|
||||
// practice that may not be soon enough, so create and enqueue an expedited one-time
|
||||
// request to get new notifications immediately.
|
||||
WorkRequest fetchNotifications = new OneTimeWorkRequest.Builder(NotificationWorker.class)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build();
|
||||
workManager.enqueue(fetchNotifications);
|
||||
|
||||
WorkRequest workRequest = new PeriodicWorkRequest.Builder(
|
||||
NotificationWorker.class,
|
||||
PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS,
|
||||
|
@ -502,6 +644,7 @@ public class NotificationHelper {
|
|||
)
|
||||
.addTag(NOTIFICATION_PULL_TAG)
|
||||
.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setInitialDelay(5, TimeUnit.MINUTES)
|
||||
.build();
|
||||
|
||||
workManager.enqueue(workRequest);
|
||||
|
@ -514,33 +657,23 @@ public class NotificationHelper {
|
|||
Log.d(TAG, "disabled notification checks");
|
||||
}
|
||||
|
||||
public static void clearNotificationsForActiveAccount(@NonNull Context context, @NonNull AccountManager accountManager) {
|
||||
AccountEntity account = accountManager.getActiveAccount();
|
||||
if (account != null && !account.getActiveNotifications().equals("[]")) {
|
||||
Single.fromCallable(() -> {
|
||||
account.setActiveNotifications("[]");
|
||||
accountManager.saveAccount(account);
|
||||
public static void clearNotificationsForAccount(@NonNull Context context, @NonNull AccountEntity account) {
|
||||
int accountId = (int) account.getId();
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel((int) account.getId());
|
||||
return true;
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe();
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
for (StatusBarNotification androidNotification : notificationManager.getActiveNotifications()) {
|
||||
if (accountId == androidNotification.getId()) {
|
||||
notificationManager.cancel(androidNotification.getTag(), androidNotification.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification notification,
|
||||
Context context) {
|
||||
return filterNotification(account, notification.getType(), context);
|
||||
public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) {
|
||||
return filterNotification(notificationManager, account, notification.getType());
|
||||
}
|
||||
|
||||
public static boolean filterNotification(AccountEntity account, Notification.Type type,
|
||||
Context context) {
|
||||
|
||||
public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification.Type type) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
String channelId = getChannelId(account, type);
|
||||
if(channelId == null) {
|
||||
// unknown notificationtype
|
||||
|
@ -610,9 +743,7 @@ public class NotificationHelper {
|
|||
|
||||
}
|
||||
|
||||
private static void setupPreferences(AccountEntity account,
|
||||
NotificationCompat.Builder builder) {
|
||||
|
||||
private static void setSoundVibrationLight(AccountEntity account, NotificationCompat.Builder builder) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
return; //do nothing on Android O or newer, the system uses the channel settings anyway
|
||||
}
|
||||
|
@ -630,28 +761,29 @@ public class NotificationHelper {
|
|||
}
|
||||
}
|
||||
|
||||
private static String wrapItemAt(JSONArray array, int index) throws JSONException {
|
||||
return StringUtils.unicodeWrap(array.get(index).toString());
|
||||
private static String wrapItemAt(StatusBarNotification notification) {
|
||||
return StringUtils.unicodeWrap(notification.getNotification().extras.getString(EXTRA_ACCOUNT_NAME));//getAccount().getName());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String joinNames(Context context, JSONArray array) throws JSONException {
|
||||
if (array.length() > 3) {
|
||||
int length = array.length();
|
||||
private static String joinNames(Context context, List<StatusBarNotification> notifications) {
|
||||
if (notifications.size() > 3) {
|
||||
int length = notifications.size();
|
||||
//notifications.get(0).getNotification().extras.getString(EXTRA_ACCOUNT_NAME);
|
||||
return String.format(context.getString(R.string.notification_summary_large),
|
||||
wrapItemAt(array, length - 1),
|
||||
wrapItemAt(array, length - 2),
|
||||
wrapItemAt(array, length - 3),
|
||||
wrapItemAt(notifications.get(length - 1)),
|
||||
wrapItemAt(notifications.get(length - 2)),
|
||||
wrapItemAt(notifications.get(length - 3)),
|
||||
length - 3);
|
||||
} else if (array.length() == 3) {
|
||||
} else if (notifications.size() == 3) {
|
||||
return String.format(context.getString(R.string.notification_summary_medium),
|
||||
wrapItemAt(array, 2),
|
||||
wrapItemAt(array, 1),
|
||||
wrapItemAt(array, 0));
|
||||
} else if (array.length() == 2) {
|
||||
wrapItemAt(notifications.get(2)),
|
||||
wrapItemAt(notifications.get(1)),
|
||||
wrapItemAt(notifications.get(0)));
|
||||
} else if (notifications.size() == 2) {
|
||||
return String.format(context.getString(R.string.notification_summary_small),
|
||||
wrapItemAt(array, 1),
|
||||
wrapItemAt(array, 0));
|
||||
wrapItemAt(notifications.get(1)),
|
||||
wrapItemAt(notifications.get(0)));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
/* Copyright 2020 Tusky Contributors
|
||||
*
|
||||
* This file is part of Tusky.
|
||||
*
|
||||
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
* Lesser 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 Lesser
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
|
||||
* not, see <http://www.gnu.org/licenses/>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
private val notificationsFetcher: NotificationFetcher
|
||||
) : Worker(context, params) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
notificationsFetcher.fetchAndShow()
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationWorkerFactory @Inject constructor(
|
||||
private val notificationsFetcher: NotificationFetcher
|
||||
) : WorkerFactory() {
|
||||
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters
|
||||
): ListenableWorker? {
|
||||
if (workerClassName == NotificationWorker::class.java.name) {
|
||||
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -41,6 +41,8 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
||||
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
|
@ -105,7 +107,7 @@ class NotificationsFragment :
|
|||
|
||||
adapter = NotificationsPagingAdapter(
|
||||
notificationDiffCallback,
|
||||
accountId = accountManager.activeAccount!!.accountId,
|
||||
accountId = viewModel.account.accountId,
|
||||
statusActionListener = this,
|
||||
notificationActionListener = this,
|
||||
accountActionListener = this,
|
||||
|
@ -193,6 +195,19 @@ class NotificationsFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SyntheticAccessor")
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
newState != SCROLL_STATE_IDLE && return
|
||||
|
||||
// Save the ID of the first notification visible in the list, so the user's
|
||||
// reading position is always restorable.
|
||||
layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position ->
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { id ->
|
||||
viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
|
||||
|
@ -253,7 +268,7 @@ class NotificationsFragment :
|
|||
Log.d(TAG, error.toString())
|
||||
val message = getString(
|
||||
error.message,
|
||||
error.exception.localizedMessage
|
||||
error.throwable.localizedMessage
|
||||
?: getString(R.string.ui_error_unknown)
|
||||
)
|
||||
val snackbar = Snackbar.make(
|
||||
|
@ -439,6 +454,10 @@ class NotificationsFragment :
|
|||
onRefresh()
|
||||
true
|
||||
}
|
||||
R.id.load_newest -> {
|
||||
viewModel.accept(InfallibleUiAction.LoadNewest)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
@ -446,7 +465,7 @@ class NotificationsFragment :
|
|||
override fun onRefresh() {
|
||||
binding.progressBar.isVisible = false
|
||||
adapter.refresh()
|
||||
NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -463,7 +482,7 @@ class NotificationsFragment :
|
|||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForActiveAccount(requireContext(), accountManager)
|
||||
NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account)
|
||||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
|
@ -592,7 +611,7 @@ class NotificationsFragment :
|
|||
|
||||
override fun onViewReport(reportId: String) {
|
||||
requireContext().openLink(
|
||||
"https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId"
|
||||
"https://${viewModel.account.domain}/admin/reports/$reportId"
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -608,7 +627,7 @@ class NotificationsFragment :
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotificationF"
|
||||
private const val TAG = "NotificationsFragment"
|
||||
fun newInstance() = NotificationsFragment()
|
||||
|
||||
private val notificationDiffCallback: DiffUtil.ItemCallback<NotificationViewData> =
|
||||
|
|
|
@ -117,10 +117,6 @@ class NotificationsPagingAdapter(
|
|||
)
|
||||
}
|
||||
|
||||
init {
|
||||
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return NotificationViewKind.from(getItem(position)?.type).ordinal
|
||||
}
|
||||
|
@ -147,13 +143,15 @@ class NotificationsPagingAdapter(
|
|||
NotificationViewKind.FOLLOW -> {
|
||||
FollowViewHolder(
|
||||
ItemFollowBinding.inflate(inflater, parent, false),
|
||||
notificationActionListener
|
||||
notificationActionListener,
|
||||
statusActionListener
|
||||
)
|
||||
}
|
||||
NotificationViewKind.FOLLOW_REQUEST -> {
|
||||
FollowRequestViewHolder(
|
||||
ItemFollowRequestBinding.inflate(inflater, parent, false),
|
||||
accountActionListener,
|
||||
statusActionListener,
|
||||
showHeader = true
|
||||
)
|
||||
}
|
||||
|
|
|
@ -31,7 +31,21 @@ import retrofit2.Response
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Models next/prev links from the "Links" header in an API response */
|
||||
data class Links(val next: String?, val prev: String?)
|
||||
data class Links(val next: String?, val prev: String?) {
|
||||
companion object {
|
||||
fun from(linkHeader: String?): Links {
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
return Links(
|
||||
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
||||
"max_id"
|
||||
),
|
||||
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
||||
"min_id"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
|
||||
class NotificationsPagingSource @Inject constructor(
|
||||
|
@ -79,7 +93,7 @@ class NotificationsPagingSource @Inject constructor(
|
|||
return LoadResult.Error(Throwable("HTTP $code: $msg"))
|
||||
}
|
||||
|
||||
val links = getPageLinks(response.headers()["link"])
|
||||
val links = Links.from(response.headers()["link"])
|
||||
return LoadResult.Page(
|
||||
data = response.body()!!,
|
||||
nextKey = links.next,
|
||||
|
@ -97,9 +111,10 @@ class NotificationsPagingSource @Inject constructor(
|
|||
* - If there is no key, a page of the most recent notifications is returned
|
||||
* - If the notification exists, and is not filtered, a page of notifications is returned
|
||||
* - If the notification does not exist, or is filtered, the page of notifications immediately
|
||||
* before is returned
|
||||
* before is returned (if non-empty)
|
||||
* - If there is no page of notifications immediately before then the page immediately after
|
||||
* is returned
|
||||
* is returned (if non-empty)
|
||||
* - Finally, fall back to the most recent notifications
|
||||
*/
|
||||
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
|
||||
// If the key is null this is straightforward, just return the most recent notifications.
|
||||
|
@ -163,36 +178,35 @@ class NotificationsPagingSource @Inject constructor(
|
|||
}
|
||||
|
||||
// The user's last read notification was missing or is filtered. Use the page of
|
||||
// notifications chronologically older than their desired notification.
|
||||
deferredNotificationPage.await().apply {
|
||||
if (this.isSuccessful) return@coroutineScope this
|
||||
// notifications chronologically older than their desired notification. This page must
|
||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||
deferredNotificationPage.await().let { response ->
|
||||
if (response.isSuccessful) {
|
||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
||||
}
|
||||
}
|
||||
|
||||
// There were no notifications older than the user's desired notification. Return the page
|
||||
// of notifications immediately newer than their desired notification.
|
||||
// of notifications immediately newer than their desired notification. This page must
|
||||
// *not* be empty (as noted earlier, if it is, paging stops).
|
||||
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
|
||||
if (response.isSuccessful) {
|
||||
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
||||
}
|
||||
}
|
||||
|
||||
// Everything failed -- fallback to fetching the most recent notifications
|
||||
return@coroutineScope mastodonApi.notifications(
|
||||
minId = key,
|
||||
limit = params.loadSize,
|
||||
excludes = notificationFilter
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPageLinks(linkHeader: String?): Links {
|
||||
val links = HttpHeaderLink.parse(linkHeader)
|
||||
return Links(
|
||||
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
||||
"max_id"
|
||||
),
|
||||
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
||||
"min_id"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||
anchorPage?.prevKey ?: anchorPage?.nextKey
|
||||
val id = state.closestItemToPosition(anchorPosition)?.id
|
||||
Log.d(TAG, " getRefreshKey returning $id")
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.BlockEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
|
@ -40,11 +41,13 @@ import com.keylesspalace.tusky.usecase.TimelineCases
|
|||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
import com.keylesspalace.tusky.util.serialize
|
||||
import com.keylesspalace.tusky.util.throttleFirst
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -52,19 +55,22 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
data class UiState(
|
||||
/** Filtered notification types */
|
||||
|
@ -118,6 +124,12 @@ sealed class InfallibleUiAction : UiAction() {
|
|||
* can do.
|
||||
*/
|
||||
data class SaveVisibleId(val visibleId: String) : InfallibleUiAction()
|
||||
|
||||
/** Ignore the saved reading position, load the page with the newest items */
|
||||
// Resets the account's `lastNotificationId`, which can't fail, which is why this is
|
||||
// infallible. Reloading the data may fail, but that's handled by the paging system /
|
||||
// adapter refresh logic.
|
||||
object LoadNewest : InfallibleUiAction()
|
||||
}
|
||||
|
||||
/** Actions the user can trigger on an individual notification. These may fail. */
|
||||
|
@ -218,7 +230,7 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() {
|
|||
/** Errors from fallible view model actions that the UI will need to show */
|
||||
sealed class UiError(
|
||||
/** The exception associated with the error */
|
||||
open val exception: Exception,
|
||||
open val throwable: Throwable,
|
||||
|
||||
/** String resource with an error message to show the user */
|
||||
@StringRes val message: Int,
|
||||
|
@ -226,55 +238,55 @@ sealed class UiError(
|
|||
/** The action that failed. Can be resent to retry the action */
|
||||
open val action: UiAction? = null
|
||||
) {
|
||||
data class ClearNotifications(override val exception: Exception) : UiError(
|
||||
exception,
|
||||
data class ClearNotifications(override val throwable: Throwable) : UiError(
|
||||
throwable,
|
||||
R.string.ui_error_clear_notifications
|
||||
)
|
||||
|
||||
data class Bookmark(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Bookmark
|
||||
) : UiError(exception, R.string.ui_error_bookmark, action)
|
||||
) : UiError(throwable, R.string.ui_error_bookmark, action)
|
||||
|
||||
data class Favourite(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Favourite
|
||||
) : UiError(exception, R.string.ui_error_favourite, action)
|
||||
) : UiError(throwable, R.string.ui_error_favourite, action)
|
||||
|
||||
data class Reblog(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.Reblog
|
||||
) : UiError(exception, R.string.ui_error_reblog, action)
|
||||
) : UiError(throwable, R.string.ui_error_reblog, action)
|
||||
|
||||
data class VoteInPoll(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: StatusAction.VoteInPoll
|
||||
) : UiError(exception, R.string.ui_error_vote, action)
|
||||
) : UiError(throwable, R.string.ui_error_vote, action)
|
||||
|
||||
data class AcceptFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.AcceptFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_accept_follow_request, action)
|
||||
) : UiError(throwable, R.string.ui_error_accept_follow_request, action)
|
||||
|
||||
data class RejectFollowRequest(
|
||||
override val exception: Exception,
|
||||
override val throwable: Throwable,
|
||||
override val action: NotificationAction.RejectFollowRequest
|
||||
) : UiError(exception, R.string.ui_error_reject_follow_request, action)
|
||||
) : UiError(throwable, R.string.ui_error_reject_follow_request, action)
|
||||
|
||||
companion object {
|
||||
fun make(exception: Exception, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(exception, action)
|
||||
is StatusAction.Favourite -> Favourite(exception, action)
|
||||
is StatusAction.Reblog -> Reblog(exception, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(exception, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(exception, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(exception, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(exception)
|
||||
fun make(throwable: Throwable, action: FallibleUiAction) = when (action) {
|
||||
is StatusAction.Bookmark -> Bookmark(throwable, action)
|
||||
is StatusAction.Favourite -> Favourite(throwable, action)
|
||||
is StatusAction.Reblog -> Reblog(throwable, action)
|
||||
is StatusAction.VoteInPoll -> VoteInPoll(throwable, action)
|
||||
is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action)
|
||||
is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action)
|
||||
FallibleUiAction.ClearNotifications -> ClearNotifications(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class)
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val repository: NotificationsRepository,
|
||||
private val preferences: SharedPreferences,
|
||||
|
@ -282,6 +294,8 @@ class NotificationsViewModel @Inject constructor(
|
|||
private val timelineCases: TimelineCases,
|
||||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
/** The account to display notifications for */
|
||||
val account = accountManager.activeAccount!!
|
||||
|
||||
val uiState: StateFlow<UiState>
|
||||
|
||||
|
@ -293,15 +307,25 @@ class NotificationsViewModel @Inject constructor(
|
|||
/** Flow of user actions received from the UI */
|
||||
private val uiAction = MutableSharedFlow<UiAction>()
|
||||
|
||||
/** Flow that can be used to trigger a full reload */
|
||||
private val reload = MutableStateFlow(0)
|
||||
|
||||
/** Flow of successful action results */
|
||||
// Note: These are a SharedFlow instead of a StateFlow because success or error state does not
|
||||
// need to be retained. A message is shown once to a user and then dismissed. Re-collecting the
|
||||
// flow (e.g., after a device orientation change) should not re-show the most recent success or
|
||||
// error message, as it will be confusing to the user.
|
||||
// Note: This is a SharedFlow instead of a StateFlow because success state does not need to be
|
||||
// retained. A message is shown once to a user and then dismissed. Re-collecting the flow
|
||||
// (e.g., after a device orientation change) should not re-show the most recent success
|
||||
// message, as it will be confusing to the user.
|
||||
val uiSuccess = MutableSharedFlow<UiSuccess>()
|
||||
|
||||
/** Flow of transient errors for the UI to present */
|
||||
val uiError = MutableSharedFlow<UiError>()
|
||||
/** Channel for error results */
|
||||
// Errors are sent to a channel to ensure that any errors that occur *before* there are any
|
||||
// subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it
|
||||
// was a StateFlow any errors would be retained, and there would need to be an explicit
|
||||
// mechanism to dismiss them.
|
||||
private val _uiErrorChannel = Channel<UiError>()
|
||||
|
||||
/** Expose UI errors as a flow */
|
||||
val uiError = _uiErrorChannel.receiveAsFlow()
|
||||
|
||||
/** Accept UI actions in to actionStateFlow */
|
||||
val accept: (UiAction) -> Unit = { action ->
|
||||
|
@ -316,31 +340,39 @@ class NotificationsViewModel @Inject constructor(
|
|||
// Save each change back to the active account
|
||||
.onEach { action ->
|
||||
Log.d(TAG, "notificationFilter: $action")
|
||||
accountManager.activeAccount?.let { account ->
|
||||
account.notificationsFilter = serialize(action.filter)
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
account.notificationsFilter = serialize(action.filter)
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
// Load the initial filter from the active account
|
||||
.onStart {
|
||||
emit(
|
||||
InfallibleUiAction.ApplyFilter(
|
||||
filter = deserialize(accountManager.activeAccount?.notificationsFilter)
|
||||
filter = deserialize(account.notificationsFilter)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Reset the last notification ID to "0" to fetch the newest notifications, and
|
||||
// increment `reload` to trigger creation of a new PagingSource.
|
||||
viewModelScope.launch {
|
||||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.LoadNewest>()
|
||||
.collectLatest {
|
||||
account.lastNotificationId = "0"
|
||||
accountManager.saveAccount(account)
|
||||
reload.getAndUpdate { it + 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// Save the visible notification ID
|
||||
viewModelScope.launch {
|
||||
uiAction
|
||||
.filterIsInstance<InfallibleUiAction.SaveVisibleId>()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { action ->
|
||||
Log.d(TAG, "Saving visible ID: ${action.visibleId}")
|
||||
accountManager.activeAccount?.let { account ->
|
||||
account.lastNotificationId = action.visibleId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}")
|
||||
account.lastNotificationId = action.visibleId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -351,7 +383,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
statusDisplayOptions = MutableStateFlow(
|
||||
StatusDisplayOptions.from(
|
||||
preferences,
|
||||
accountManager.activeAccount!!
|
||||
account
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -363,7 +395,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
statusDisplayOptions.value.make(
|
||||
preferences,
|
||||
it.preferenceKey,
|
||||
accountManager.activeAccount!!
|
||||
account
|
||||
)
|
||||
}
|
||||
.collect {
|
||||
|
@ -380,11 +412,11 @@ class NotificationsViewModel @Inject constructor(
|
|||
if (this.isSuccessful) {
|
||||
repository.invalidate()
|
||||
} else {
|
||||
uiError.emit(UiError.make(HttpException(this), it))
|
||||
_uiErrorChannel.send(UiError.make(HttpException(this), it))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, it)) }
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -392,7 +424,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
// Handle NotificationAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<NotificationAction>()
|
||||
.debounce(DEBOUNCE_TIMEOUT_MS)
|
||||
.throttleFirst(THROTTLE_TIMEOUT)
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
|
@ -403,7 +435,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
uiSuccess.emit(NotificationActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -411,7 +443,7 @@ class NotificationsViewModel @Inject constructor(
|
|||
// Handle StatusAction.*
|
||||
viewModelScope.launch {
|
||||
uiAction.filterIsInstance<StatusAction>()
|
||||
.debounce(DEBOUNCE_TIMEOUT_MS) // avoid double-taps
|
||||
.throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps
|
||||
.collect { action ->
|
||||
try {
|
||||
when (action) {
|
||||
|
@ -436,10 +468,10 @@ class NotificationsViewModel @Inject constructor(
|
|||
action.poll.id,
|
||||
action.choices
|
||||
)
|
||||
}
|
||||
}.getOrThrow()
|
||||
uiSuccess.emit(StatusActionSuccess.from(action))
|
||||
} catch (e: Exception) {
|
||||
ifExpected(e) { uiError.emit(UiError.make(e, action)) }
|
||||
} catch (t: Throwable) {
|
||||
_uiErrorChannel.send(UiError.make(t, action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -455,19 +487,12 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// The database stores "0" as the last notification ID if notifications have not been
|
||||
// fetched. Convert to null to ensure a full fetch in this case
|
||||
val lastNotificationId = when (val id = accountManager.activeAccount?.lastNotificationId) {
|
||||
"0" -> null
|
||||
else -> id
|
||||
}
|
||||
Log.d(TAG, "Restoring at $lastNotificationId")
|
||||
|
||||
pagingData = notificationFilter
|
||||
// Re-fetch notifications if either of `notificationFilter` or `reload` flows have
|
||||
// new items.
|
||||
pagingData = combine(notificationFilter, reload) { action, _ -> action }
|
||||
.flatMapLatest { action ->
|
||||
getNotifications(filters = action.filter, initialKey = lastNotificationId)
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
getNotifications(filters = action.filter, initialKey = getInitialKey())
|
||||
}.cachedIn(viewModelScope)
|
||||
|
||||
uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs ->
|
||||
UiState(
|
||||
|
@ -499,6 +524,17 @@ class NotificationsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// The database stores "0" as the last notification ID if notifications have not been
|
||||
// fetched. Convert to null to ensure a full fetch in this case
|
||||
private fun getInitialKey(): String? {
|
||||
val initialKey = when (val id = account.lastNotificationId) {
|
||||
"0" -> null
|
||||
else -> id
|
||||
}
|
||||
Log.d(TAG, "Restoring at $initialKey")
|
||||
return initialKey
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Flow of relevant preferences that change the UI
|
||||
*/
|
||||
|
@ -516,6 +552,6 @@ class NotificationsViewModel @Inject constructor(
|
|||
|
||||
companion object {
|
||||
private const val TAG = "NotificationsViewModel"
|
||||
private const val DEBOUNCE_TIMEOUT_MS = 500L
|
||||
private val THROTTLE_TIMEOUT = 500.milliseconds
|
||||
}
|
||||
}
|
||||
|
|
|
@ -151,8 +151,9 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) {
|
|||
|
||||
private fun buildSubscriptionData(context: Context, account: AccountEntity): Map<String, Boolean> =
|
||||
buildMap {
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
Notification.Type.visibleTypes.forEach {
|
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(account, it, context))
|
||||
put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(notificationManager, account, it))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
@ -30,7 +29,6 @@ import com.keylesspalace.tusky.BuildConfig
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.TabPreferenceActivity
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
|
||||
import com.keylesspalace.tusky.components.filters.FiltersActivity
|
||||
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
|
||||
|
@ -42,7 +40,7 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.settings.AccountPreferenceHandler
|
||||
import com.keylesspalace.tusky.settings.AccountPreferenceDataStore
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.settings.listPreference
|
||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||
|
@ -58,7 +56,6 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeRes
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
@ -74,6 +71,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
@Inject
|
||||
lateinit var accountPreferenceDataStore: AccountPreferenceDataStore
|
||||
|
||||
private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
|
@ -245,27 +245,26 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
preferenceCategory(R.string.pref_title_timelines) {
|
||||
// TODO having no activeAccount in this fragment does not really make sense, enforce it?
|
||||
// All other locations here make it optional, however.
|
||||
val accountPreferenceHandler = AccountPreferenceHandler(accountManager.activeAccount!!, accountManager, ::dispatchEvent)
|
||||
|
||||
switchPreference {
|
||||
key = PrefKeys.MEDIA_PREVIEW_ENABLED
|
||||
setTitle(R.string.pref_title_show_media_preview)
|
||||
isSingleLineTitle = false
|
||||
preferenceDataStore = accountPreferenceHandler
|
||||
preferenceDataStore = accountPreferenceDataStore
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA
|
||||
setTitle(R.string.pref_title_alway_show_sensitive_media)
|
||||
isSingleLineTitle = false
|
||||
preferenceDataStore = accountPreferenceHandler
|
||||
preferenceDataStore = accountPreferenceDataStore
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
key = PrefKeys.ALWAYS_OPEN_SPOILER
|
||||
setTitle(R.string.pref_title_alway_open_spoiler)
|
||||
isSingleLineTitle = false
|
||||
preferenceDataStore = accountPreferenceHandler
|
||||
preferenceDataStore = accountPreferenceDataStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -353,12 +352,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
|
||||
}
|
||||
|
||||
private fun dispatchEvent(event: PreferenceChangedEvent) {
|
||||
lifecycleScope.launch {
|
||||
eventHub.dispatch(event)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = AccountPreferencesFragment()
|
||||
}
|
||||
|
|
|
@ -95,7 +95,9 @@ class PreferencesActivity :
|
|||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback)
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = intent.extras?.getBoolean(
|
||||
EXTRA_RESTART_ON_BACK
|
||||
) ?: savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) ?: false
|
||||
}
|
||||
|
||||
override fun onPreferenceStartFragment(
|
||||
|
@ -151,6 +153,10 @@ class PreferencesActivity :
|
|||
restartActivitiesOnBackPressedCallback.isEnabled = true
|
||||
this.restartCurrentActivity()
|
||||
}
|
||||
PrefKeys.UI_TEXT_SCALE_RATIO -> {
|
||||
restartActivitiesOnBackPressedCallback.isEnabled = true
|
||||
this.restartCurrentActivity()
|
||||
}
|
||||
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash",
|
||||
"showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites",
|
||||
"enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> {
|
||||
|
@ -175,7 +181,8 @@ class PreferencesActivity :
|
|||
override fun androidInjector() = androidInjector
|
||||
|
||||
companion object {
|
||||
|
||||
@Suppress("unused")
|
||||
private const val TAG = "PreferencesActivity"
|
||||
const val GENERAL_PREFERENCES = 0
|
||||
const val ACCOUNT_PREFERENCES = 1
|
||||
const val NOTIFICATION_PREFERENCES = 2
|
||||
|
|
|
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.settings.listPreference
|
|||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||
import com.keylesspalace.tusky.settings.preference
|
||||
import com.keylesspalace.tusky.settings.preferenceCategory
|
||||
import com.keylesspalace.tusky.settings.sliderPreference
|
||||
import com.keylesspalace.tusky.settings.switchPreference
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.deserialize
|
||||
|
@ -99,6 +100,19 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
preferenceDataStore = localeManager
|
||||
}
|
||||
|
||||
sliderPreference {
|
||||
key = PrefKeys.UI_TEXT_SCALE_RATIO
|
||||
setDefaultValue(100F)
|
||||
valueTo = 150F
|
||||
valueFrom = 50F
|
||||
stepSize = 5F
|
||||
setTitle(R.string.pref_ui_text_size)
|
||||
format = "%.0f%%"
|
||||
decrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_out)
|
||||
incrementIcon = makeIcon(GoogleMaterial.Icon.gmd_zoom_in)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_format_size)
|
||||
}
|
||||
|
||||
listPreference {
|
||||
setDefaultValue("medium")
|
||||
setEntries(R.array.post_text_size_names)
|
||||
|
|
|
@ -71,6 +71,11 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
private fun initViewPager() {
|
||||
binding.wizard.isUserInputEnabled = false
|
||||
|
||||
// Odd workaround for text field losing focus on first focus
|
||||
// (unfixed old bug: https://github.com/material-components/material-components-android/issues/500)
|
||||
binding.wizard.offscreenPageLimit = 1
|
||||
|
||||
binding.wizard.adapter = ReportPagerAdapter(this)
|
||||
}
|
||||
|
||||
|
|
|
@ -47,7 +47,6 @@ import com.mikepenz.iconics.utils.colorInt
|
|||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScheduledStatusActivity :
|
||||
|
@ -102,15 +101,7 @@ class ScheduledStatusActivity :
|
|||
binding.errorMessageView.show()
|
||||
|
||||
val errorState = loadState.refresh as LoadState.Error
|
||||
if (errorState.error is IOException) {
|
||||
binding.errorMessageView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
refreshStatuses()
|
||||
}
|
||||
} else {
|
||||
binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
refreshStatuses()
|
||||
}
|
||||
}
|
||||
binding.errorMessageView.setup(errorState.error) { refreshStatuses() }
|
||||
}
|
||||
if (loadState.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
|
|
|
@ -46,11 +46,8 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
var currentQuery: String = ""
|
||||
|
||||
var activeAccount: AccountEntity?
|
||||
val activeAccount: AccountEntity?
|
||||
get() = accountManager.activeAccount
|
||||
set(value) {
|
||||
accountManager.activeAccount = value
|
||||
}
|
||||
|
||||
val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled ?: false
|
||||
val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||
|
|
|
@ -15,15 +15,28 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.search.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
|
||||
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class SearchAccountsFragment : SearchFragment<TimelineAccount>() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.searchRecyclerView.addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
binding.searchRecyclerView.context,
|
||||
DividerItemDecoration.VERTICAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun createAdapter(): PagingDataAdapter<TimelineAccount, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context)
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import androidx.lifecycle.lifecycleScope
|
|||
import androidx.paging.LoadState
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
|
@ -129,7 +128,6 @@ abstract class SearchFragment<T : Any> :
|
|||
}
|
||||
|
||||
private fun initAdapter() {
|
||||
binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||
binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context)
|
||||
adapter = createAdapter()
|
||||
binding.searchRecyclerView.adapter = adapter
|
||||
|
|
|
@ -15,8 +15,11 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.search.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -26,6 +29,16 @@ class SearchHashtagsFragment : SearchFragment<HashTag>() {
|
|||
override val data: Flow<PagingData<HashTag>>
|
||||
get() = viewModel.hashtagsFlow
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.searchRecyclerView.addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
binding.searchRecyclerView.context,
|
||||
DividerItemDecoration.VERTICAL
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun createAdapter(): PagingDataAdapter<HashTag, *> = SearchHashtagsAdapter(this)
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -77,10 +77,8 @@ import com.mikepenz.iconics.utils.colorInt
|
|||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -243,16 +241,7 @@ class TimelineFragment :
|
|||
}
|
||||
is LoadState.Error -> {
|
||||
binding.statusView.show()
|
||||
|
||||
if ((loadState.refresh as LoadState.Error).error is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
onRefresh()
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() }
|
||||
}
|
||||
is LoadState.Loading -> {
|
||||
binding.progressBar.show()
|
||||
|
@ -578,6 +567,17 @@ class TimelineFragment :
|
|||
|
||||
private var talkBackWasEnabled = false
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position ->
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
adapter.snapshot().getOrNull(position)?.id?.let { statusId ->
|
||||
viewModel.saveReadingPosition(statusId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val a11yManager =
|
||||
|
|
|
@ -50,14 +50,11 @@ import com.keylesspalace.tusky.util.EmptyPagingSource
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.asExecutor
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
/**
|
||||
* TimelineViewModel that caches all statuses in a local database
|
||||
|
@ -107,16 +104,6 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
.flowOn(Dispatchers.Default)
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
delay(5.toDuration(DurationUnit.SECONDS)) // delay so the db is not locked during initial ui refresh
|
||||
accountManager.activeAccount?.id?.let { accountId ->
|
||||
db.timelineDao().cleanup(accountId, MAX_STATUSES_IN_CACHE)
|
||||
db.timelineDao().cleanupAccounts(accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {
|
||||
// handled by CacheUpdater
|
||||
}
|
||||
|
@ -289,6 +276,14 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun saveReadingPosition(statusId: String) {
|
||||
accountManager.activeAccount?.let { account ->
|
||||
Log.d(TAG, "Saving position at: $statusId")
|
||||
account.lastVisibleHomeTimelineStatusId = statusId
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
|
||||
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
|
||||
|
@ -297,6 +292,7 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CachedTimelineViewModel"
|
||||
private const val MAX_STATUSES_IN_CACHE = 1000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -255,6 +255,10 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun saveReadingPosition(statusId: String) {
|
||||
/** Does nothing for non-cached timelines */
|
||||
}
|
||||
|
||||
override suspend fun invalidate() {
|
||||
currentSource?.invalidate()
|
||||
}
|
||||
|
|
|
@ -182,6 +182,9 @@ abstract class TimelineViewModel(
|
|||
|
||||
abstract fun clearWarning(status: StatusViewData.Concrete)
|
||||
|
||||
/** Saves the user's reading position so it can be restored later */
|
||||
abstract fun saveReadingPosition(statusId: String)
|
||||
|
||||
/** Triggered when currently displayed data must be reloaded. */
|
||||
protected abstract suspend fun invalidate()
|
||||
|
||||
|
|
|
@ -19,23 +19,19 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.commit
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
|
||||
class TrendingActivity : BaseActivity(), HasAndroidInjector {
|
||||
|
||||
@Inject
|
||||
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -44,10 +40,8 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar)
|
||||
|
||||
val title = getString(R.string.title_public_trending_hashtags)
|
||||
|
||||
supportActionBar?.run {
|
||||
setTitle(title)
|
||||
setTitle(R.string.title_public_trending_hashtags)
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
@ -63,10 +57,6 @@ class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
override fun androidInjector() = dispatchingAndroidInjector
|
||||
|
||||
companion object {
|
||||
const val TAG = "TrendingActivity"
|
||||
|
||||
@JvmStatic
|
||||
fun getIntent(context: Context) =
|
||||
Intent(context, TrendingActivity::class.java)
|
||||
fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,15 +20,12 @@ import android.view.ViewGroup
|
|||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
|
||||
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
||||
class TrendingAdapter(
|
||||
private val trendingListener: LinkListener
|
||||
private val onViewTag: (String) -> Unit
|
||||
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
|
||||
|
||||
init {
|
||||
|
@ -42,7 +39,6 @@ class TrendingAdapter(
|
|||
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
|
||||
TrendingTagViewHolder(binding)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val binding =
|
||||
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
|
||||
|
@ -52,38 +48,15 @@ class TrendingAdapter(
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
|
||||
bindViewHolder(viewHolder, position, null)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>
|
||||
) {
|
||||
bindViewHolder(viewHolder, position, payloads)
|
||||
}
|
||||
|
||||
private fun bindViewHolder(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<*>?
|
||||
) {
|
||||
when (val header = getItem(position)) {
|
||||
when (val viewData = getItem(position)) {
|
||||
is TrendingViewData.Tag -> {
|
||||
val maxTrendingValue = currentList
|
||||
.flatMap { trendingViewData ->
|
||||
trendingViewData.asTagOrNull()?.tag?.history.orEmpty()
|
||||
}
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
.maxOrNull() ?: 1
|
||||
|
||||
val holder = viewHolder as TrendingTagViewHolder
|
||||
holder.setup(header, maxTrendingValue, trendingListener)
|
||||
holder.setup(viewData, onViewTag)
|
||||
}
|
||||
|
||||
is TrendingViewData.Header -> {
|
||||
val holder = viewHolder as TrendingDateViewHolder
|
||||
holder.setup(header.start, header.end)
|
||||
holder.setup(viewData.start, viewData.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -112,14 +85,7 @@ class TrendingAdapter(
|
|||
oldItem: TrendingViewData,
|
||||
newItem: TrendingViewData
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: TrendingViewData,
|
||||
newItem: TrendingViewData
|
||||
): Any? {
|
||||
return null
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
|
@ -15,17 +15,14 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
|
||||
|
@ -33,18 +30,14 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.keylesspalace.tusky.BottomSheetActivity
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
|
||||
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
|
@ -56,48 +49,20 @@ import kotlinx.coroutines.launch
|
|||
import javax.inject.Inject
|
||||
|
||||
class TrendingFragment :
|
||||
Fragment(),
|
||||
Fragment(R.layout.fragment_trending),
|
||||
OnRefreshListener,
|
||||
LinkListener,
|
||||
Injectable,
|
||||
ReselectableFragment,
|
||||
RefreshableFragment {
|
||||
|
||||
private lateinit var bottomSheetActivity: BottomSheetActivity
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
private val viewModel: TrendingViewModel by lazy {
|
||||
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
|
||||
}
|
||||
private val viewModel: TrendingViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentTrendingBinding::bind)
|
||||
|
||||
private lateinit var adapter: TrendingAdapter
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
bottomSheetActivity = if (context is BottomSheetActivity) {
|
||||
context
|
||||
} else {
|
||||
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
adapter = TrendingAdapter(
|
||||
this
|
||||
)
|
||||
}
|
||||
private val adapter = TrendingAdapter(::onViewTag)
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
|
@ -106,14 +71,6 @@ class TrendingFragment :
|
|||
setupLayoutManager(columnCount)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_trending, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupSwipeRefreshLayout()
|
||||
setupRecyclerView()
|
||||
|
@ -175,25 +132,19 @@ class TrendingFragment :
|
|||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
viewModel.invalidate()
|
||||
viewModel.invalidate(true)
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String) {
|
||||
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) {
|
||||
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) {
|
||||
bottomSheetActivity.viewAccount(id)
|
||||
fun onViewTag(tag: String) {
|
||||
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
|
||||
Log.d(TAG, uiState.loadingState.name)
|
||||
when (uiState.loadingState) {
|
||||
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
|
||||
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
|
||||
TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState()
|
||||
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
|
||||
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
|
||||
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
|
||||
|
@ -203,8 +154,9 @@ class TrendingFragment :
|
|||
private fun applyLoadedState(viewData: List<TrendingViewData>) {
|
||||
clearLoadingState()
|
||||
|
||||
adapter.submitList(viewData)
|
||||
|
||||
if (viewData.isEmpty()) {
|
||||
adapter.submitList(emptyList())
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.show()
|
||||
binding.messageView.setup(
|
||||
|
@ -213,16 +165,16 @@ class TrendingFragment :
|
|||
null
|
||||
)
|
||||
} else {
|
||||
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
|
||||
|
||||
adapter.submitList(viewDataWithDates)
|
||||
|
||||
binding.recyclerView.show()
|
||||
binding.messageView.hide()
|
||||
}
|
||||
binding.progressBar.hide()
|
||||
}
|
||||
|
||||
private fun applyRefreshingState() {
|
||||
binding.swipeRefreshLayout.isRefreshing = true
|
||||
}
|
||||
|
||||
private fun applyLoadingState() {
|
||||
binding.recyclerView.hide()
|
||||
binding.messageView.hide()
|
||||
|
@ -297,8 +249,6 @@ class TrendingFragment :
|
|||
companion object {
|
||||
private const val TAG = "TrendingFragment"
|
||||
|
||||
fun newInstance(): TrendingFragment {
|
||||
return TrendingFragment()
|
||||
}
|
||||
fun newInstance() = TrendingFragment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.trending
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
|
||||
import com.keylesspalace.tusky.util.formatNumber
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
||||
class TrendingTagViewHolder(
|
||||
private val binding: ItemTrendingCellBinding
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun setup(
|
||||
tagViewData: TrendingViewData.Tag,
|
||||
onViewTag: (String) -> Unit
|
||||
) {
|
||||
binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name)
|
||||
|
||||
binding.graph.maxTrendingValue = tagViewData.maxTrendingValue
|
||||
binding.graph.primaryLineData = tagViewData.usage
|
||||
binding.graph.secondaryLineData = tagViewData.accounts
|
||||
|
||||
binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000)
|
||||
|
||||
val totalAccounts = tagViewData.accounts.sum()
|
||||
binding.totalAccounts.text = formatNumber(totalAccounts, 1000)
|
||||
|
||||
binding.currentUsage.text = tagViewData.usage.last().toString()
|
||||
binding.currentAccounts.text = tagViewData.usage.last().toString()
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onViewTag(tagViewData.name)
|
||||
}
|
||||
|
||||
itemView.contentDescription =
|
||||
itemView.context.getString(
|
||||
R.string.accessibility_talking_about_tag,
|
||||
totalAccounts,
|
||||
tagViewData.name
|
||||
)
|
||||
}
|
||||
}
|
|
@ -15,11 +15,15 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.trending.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.end
|
||||
import com.keylesspalace.tusky.entity.start
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.toViewData
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
@ -28,7 +32,7 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.IOException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class TrendingViewModel @Inject constructor(
|
||||
|
@ -36,7 +40,7 @@ class TrendingViewModel @Inject constructor(
|
|||
private val eventHub: EventHub
|
||||
) : ViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
||||
data class TrendingUiState(
|
||||
|
@ -67,37 +71,43 @@ class TrendingViewModel @Inject constructor(
|
|||
*
|
||||
* A tag is excluded if it is filtered by the user on their home timeline.
|
||||
*/
|
||||
fun invalidate() = viewModelScope.launch {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
|
||||
|
||||
try {
|
||||
val deferredFilters = async { mastodonApi.getFilters() }
|
||||
val response = mastodonApi.trendingTags()
|
||||
if (!response.isSuccessful) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val homeFilters = deferredFilters.await().getOrNull()?.filter {
|
||||
it.context.contains(Filter.Kind.HOME.kind)
|
||||
}
|
||||
|
||||
val tags = response.body()!!
|
||||
.filter {
|
||||
homeFilters?.none { filter ->
|
||||
filter.keywords.any { keyword -> keyword.keyword.equals(it.name, ignoreCase = true) }
|
||||
} ?: false
|
||||
}
|
||||
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||
.map { it.toViewData() }
|
||||
.asReversed()
|
||||
|
||||
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
|
||||
} catch (e: IOException) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
|
||||
fun invalidate(refresh: Boolean = false) = viewModelScope.launch {
|
||||
if (refresh) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING)
|
||||
} else {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
|
||||
}
|
||||
|
||||
val deferredFilters = async { mastodonApi.getFilters() }
|
||||
|
||||
mastodonApi.trendingTags().fold(
|
||||
{ tagResponse ->
|
||||
val homeFilters = deferredFilters.await().getOrNull()?.filter { filter ->
|
||||
filter.context.contains(Filter.Kind.HOME.kind)
|
||||
}
|
||||
val tags = tagResponse
|
||||
.filter { tag ->
|
||||
homeFilters?.none { filter ->
|
||||
filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) }
|
||||
} ?: false
|
||||
}
|
||||
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||
.toViewData()
|
||||
|
||||
val firstTag = tagResponse.first()
|
||||
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
|
||||
|
||||
_uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED)
|
||||
},
|
||||
{ error ->
|
||||
Log.w(TAG, "failed loading trending tags", error)
|
||||
if (error is IOException) {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
|
||||
} else {
|
||||
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -60,7 +60,6 @@ import kotlinx.coroutines.CoroutineStart
|
|||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewThreadFragment :
|
||||
|
@ -201,21 +200,7 @@ class ViewThreadFragment :
|
|||
binding.recyclerView.hide()
|
||||
binding.statusView.show()
|
||||
|
||||
if (uiState.throwable is IOException) {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_offline,
|
||||
R.string.error_network
|
||||
) {
|
||||
viewModel.retry(thisThreadsStatusId)
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) {
|
||||
viewModel.retry(thisThreadsStatusId)
|
||||
}
|
||||
}
|
||||
binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) }
|
||||
}
|
||||
is ThreadUiState.Success -> {
|
||||
if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) {
|
||||
|
@ -416,13 +401,14 @@ class ViewThreadFragment :
|
|||
}
|
||||
|
||||
public override fun removeItem(position: Int) {
|
||||
val status = adapter.currentList[position]
|
||||
if (status.isDetailed) {
|
||||
// the main status we are viewing is being removed, finish the activity
|
||||
activity?.finish()
|
||||
return
|
||||
adapter.currentList.getOrNull(position)?.let { status ->
|
||||
if (status.isDetailed) {
|
||||
// the main status we are viewing is being removed, finish the activity
|
||||
activity?.finish()
|
||||
return
|
||||
}
|
||||
viewModel.removeStatus(status)
|
||||
}
|
||||
viewModel.removeStatus(status)
|
||||
}
|
||||
|
||||
override fun onVoteInPoll(position: Int, choices: List<Int>) {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package com.keylesspalace.tusky.components.viewthread.edits
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface.DEFAULT_BOLD
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
|
@ -11,7 +9,9 @@ import android.text.Html
|
|||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.ReplacementSpan
|
||||
import android.text.TextPaint
|
||||
import android.text.style.CharacterStyle
|
||||
import android.util.TypedValue
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -33,11 +33,9 @@ import com.keylesspalace.tusky.util.aspectRatios
|
|||
import com.keylesspalace.tusky.util.decodeBlurHash
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.viewdata.toViewData
|
||||
import org.xml.sax.XMLReader
|
||||
|
@ -52,13 +50,28 @@ class ViewEditsAdapter(
|
|||
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
/** Size of large text in this theme, in px */
|
||||
var largeTextSizePx: Float = 0f
|
||||
|
||||
/** Size of medium text in this theme, in px */
|
||||
var mediumTextSizePx: Float = 0f
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): BindingHolder<ItemStatusEditBinding> {
|
||||
val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
|
||||
binding.statusEditMediaPreview.clipToOutline = true
|
||||
|
||||
val typedValue = TypedValue()
|
||||
val context = binding.root.context
|
||||
val displayMetrics = context.resources.displayMetrics
|
||||
context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true)
|
||||
largeTextSizePx = typedValue.getDimension(displayMetrics)
|
||||
context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true)
|
||||
mediumTextSizePx = typedValue.getDimension(displayMetrics)
|
||||
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
|
@ -69,24 +82,26 @@ class ViewEditsAdapter(
|
|||
|
||||
val context = binding.root.context
|
||||
|
||||
val avatarRadius: Int = context.resources
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
|
||||
loadAvatar(edit.account.avatar, binding.statusEditAvatar, avatarRadius, animateAvatars)
|
||||
|
||||
val infoStringRes = if (position == edits.size - 1) {
|
||||
val infoStringRes = if (position == edits.lastIndex) {
|
||||
R.string.status_created_info
|
||||
} else {
|
||||
R.string.status_edit_info
|
||||
}
|
||||
|
||||
// Show the most recent version of the status using large text to make it clearer for
|
||||
// the user, and for similarity with thread view.
|
||||
val variableTextSize = if (position == edits.lastIndex) {
|
||||
mediumTextSizePx
|
||||
} else {
|
||||
largeTextSizePx
|
||||
}
|
||||
binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
|
||||
binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
|
||||
binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize)
|
||||
|
||||
val timestamp = absoluteTimeFormatter.format(edit.createdAt, false)
|
||||
|
||||
binding.statusEditInfo.text = context.getString(
|
||||
infoStringRes,
|
||||
edit.account.name.unicodeWrap(),
|
||||
timestamp
|
||||
).emojify(edit.account.emojis, binding.statusEditInfo, animateEmojis)
|
||||
binding.statusEditInfo.text = context.getString(infoStringRes, timestamp)
|
||||
|
||||
if (edit.spoilerText.isEmpty()) {
|
||||
binding.statusEditContentWarningDescription.hide()
|
||||
|
@ -198,6 +213,11 @@ class ViewEditsAdapter(
|
|||
}
|
||||
|
||||
override fun getItemCount() = edits.size
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_EDITS_NEWEST = 0
|
||||
private const val VIEW_TYPE_EDITS = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -266,98 +286,31 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A span that draws text with additional padding at the start/end of the text. The padding
|
||||
* is the width of [separator].
|
||||
*
|
||||
* Note: The separator string is not included in the final text, so it will not be included
|
||||
* if the user cuts or copies the text.
|
||||
*/
|
||||
open class LRPaddedSpan(val separator: String = " ") : ReplacementSpan() {
|
||||
/** The width of the separator string, used as padding */
|
||||
var paddingWidth = 0f
|
||||
|
||||
/** Measured width of the span */
|
||||
var spanWidth = 0f
|
||||
|
||||
override fun getSize(
|
||||
paint: Paint,
|
||||
text: CharSequence?,
|
||||
start: Int,
|
||||
end: Int,
|
||||
fm: Paint.FontMetricsInt?
|
||||
): Int {
|
||||
paddingWidth = paint.measureText(separator, 0, separator.length)
|
||||
spanWidth = (paddingWidth * 2) + paint.measureText(text, start, end)
|
||||
return spanWidth.toInt()
|
||||
}
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
text: CharSequence?,
|
||||
start: Int,
|
||||
end: Int,
|
||||
x: Float,
|
||||
top: Int,
|
||||
y: Int,
|
||||
bottom: Int,
|
||||
paint: Paint
|
||||
) {
|
||||
canvas.drawText(text?.subSequence(start, end).toString(), x + paddingWidth, y.toFloat(), paint)
|
||||
}
|
||||
}
|
||||
|
||||
/** Span that signifies deleted text */
|
||||
class DeletedTextSpan(context: Context) : LRPaddedSpan() {
|
||||
private val bgPaint = Paint()
|
||||
val radius: Float
|
||||
class DeletedTextSpan(context: Context) : CharacterStyle() {
|
||||
private var bgColor: Int
|
||||
|
||||
init {
|
||||
bgPaint.color = context.getColor(R.color.view_edits_background_delete)
|
||||
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
|
||||
bgColor = context.getColor(R.color.view_edits_background_delete)
|
||||
}
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
text: CharSequence?,
|
||||
start: Int,
|
||||
end: Int,
|
||||
x: Float,
|
||||
top: Int,
|
||||
y: Int,
|
||||
bottom: Int,
|
||||
paint: Paint
|
||||
) {
|
||||
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
|
||||
paint.isStrikeThruText = true
|
||||
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
|
||||
override fun updateDrawState(tp: TextPaint) {
|
||||
tp.bgColor = bgColor
|
||||
tp.isStrikeThruText = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Span that signifies inserted text */
|
||||
class InsertedTextSpan(context: Context) : LRPaddedSpan() {
|
||||
val bgPaint = Paint()
|
||||
val radius: Float
|
||||
class InsertedTextSpan(context: Context) : CharacterStyle() {
|
||||
private var bgColor: Int
|
||||
|
||||
init {
|
||||
bgPaint.color = context.getColor(R.color.view_edits_background_insert)
|
||||
radius = context.resources.getDimension(R.dimen.lrPaddedSpanRadius)
|
||||
bgColor = context.getColor(R.color.view_edits_background_insert)
|
||||
}
|
||||
|
||||
override fun draw(
|
||||
canvas: Canvas,
|
||||
text: CharSequence?,
|
||||
start: Int,
|
||||
end: Int,
|
||||
x: Float,
|
||||
top: Int,
|
||||
y: Int,
|
||||
bottom: Int,
|
||||
paint: Paint
|
||||
) {
|
||||
canvas.drawRoundRect(x, top.toFloat(), x + spanWidth, bottom.toFloat(), radius, radius, bgPaint)
|
||||
paint.typeface = DEFAULT_BOLD
|
||||
super.draw(canvas, text, start, end, x, top, y, bottom, paint)
|
||||
override fun updateDrawState(tp: TextPaint) {
|
||||
tp.bgColor = bgColor
|
||||
tp.typeface = DEFAULT_BOLD
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -37,24 +37,26 @@ import com.keylesspalace.tusky.BottomSheetActivity
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding
|
||||
import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.loadAvatar
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.unicodeWrap
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
class ViewEditsFragment :
|
||||
Fragment(R.layout.fragment_view_thread),
|
||||
Fragment(R.layout.fragment_view_edits),
|
||||
LinkListener,
|
||||
OnRefreshListener,
|
||||
MenuProvider,
|
||||
|
@ -65,7 +67,7 @@ class ViewEditsFragment :
|
|||
|
||||
private val viewModel: ViewEditsViewModel by viewModels { viewModelFactory }
|
||||
|
||||
private val binding by viewBinding(FragmentViewThreadBinding::bind)
|
||||
private val binding by viewBinding(FragmentViewEditsBinding::bind)
|
||||
|
||||
private lateinit var statusId: String
|
||||
|
||||
|
@ -88,6 +90,7 @@ class ViewEditsFragment :
|
|||
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true)
|
||||
val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.uiState.collect { uiState ->
|
||||
|
@ -107,13 +110,17 @@ class ViewEditsFragment :
|
|||
binding.statusView.show()
|
||||
binding.initialProgressBar.hide()
|
||||
|
||||
if (uiState.throwable is IOException) {
|
||||
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
|
||||
viewModel.loadEdits(statusId, force = true)
|
||||
when (uiState.throwable) {
|
||||
is ViewEditsViewModel.MissingEditsException -> {
|
||||
binding.statusView.setup(
|
||||
R.drawable.elephant_friend_empty,
|
||||
R.string.error_missing_edits
|
||||
)
|
||||
}
|
||||
} else {
|
||||
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
viewModel.loadEdits(statusId, force = true)
|
||||
else -> {
|
||||
binding.statusView.setup(uiState.throwable) {
|
||||
viewModel.loadEdits(statusId, force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +137,15 @@ class ViewEditsFragment :
|
|||
useBlurhash = useBlurhash,
|
||||
listener = this@ViewEditsFragment
|
||||
)
|
||||
|
||||
// Focus on the most recent version
|
||||
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0)
|
||||
|
||||
val account = uiState.edits.first().account
|
||||
loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars)
|
||||
|
||||
binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis)
|
||||
binding.statusUsername.text = account.username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ package com.keylesspalace.tusky.components.viewthread.edits
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL
|
||||
import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL
|
||||
import com.keylesspalace.tusky.entity.StatusEdit
|
||||
|
@ -48,6 +48,9 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
|
|||
private val _uiState: MutableStateFlow<EditsUiState> = MutableStateFlow(EditsUiState.Initial)
|
||||
val uiState: StateFlow<EditsUiState> = _uiState.asStateFlow()
|
||||
|
||||
/** The API call to fetch edit history returned less than two items */
|
||||
object MissingEditsException : Exception()
|
||||
|
||||
fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) {
|
||||
if (!force && _uiState.value !is EditsUiState.Initial) return
|
||||
|
||||
|
@ -58,66 +61,68 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie
|
|||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
api.statusEdits(statusId).fold(
|
||||
{ edits ->
|
||||
// Diff each status' content against the previous version, producing new
|
||||
// content with additional `ins` or `del` elements marking inserted or
|
||||
// deleted content.
|
||||
//
|
||||
// This can be CPU intensive depending on the number of edits and the size
|
||||
// of each, so don't run this on Dispatchers.Main.
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val sortedEdits = edits.sortedBy { it.createdAt }
|
||||
.reversed()
|
||||
.toMutableList()
|
||||
val edits = api.statusEdits(statusId).getOrElse {
|
||||
_uiState.value = EditsUiState.Error(it)
|
||||
return@launch
|
||||
}
|
||||
|
||||
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
|
||||
val loader = SAXLoader()
|
||||
loader.config = DiffConfig(
|
||||
false,
|
||||
WhiteSpaceProcessing.PRESERVE,
|
||||
TextGranularity.SPACE_WORD
|
||||
// `edits` might have fewer than the minimum number of entries because of
|
||||
// https://github.com/mastodon/mastodon/issues/25398.
|
||||
if (edits.size < 2) {
|
||||
_uiState.value = EditsUiState.Error(MissingEditsException)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Diff each status' content against the previous version, producing new
|
||||
// content with additional `ins` or `del` elements marking inserted or
|
||||
// deleted content.
|
||||
//
|
||||
// This can be CPU intensive depending on the number of edits and the size
|
||||
// of each, so don't run this on Dispatchers.Main.
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val sortedEdits = edits.sortedBy { it.createdAt }
|
||||
.reversed()
|
||||
.toMutableList()
|
||||
|
||||
SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver")
|
||||
val loader = SAXLoader()
|
||||
loader.config = DiffConfig(
|
||||
false,
|
||||
WhiteSpaceProcessing.PRESERVE,
|
||||
TextGranularity.SPACE_WORD
|
||||
)
|
||||
val processor = OptimisticXMLProcessor()
|
||||
processor.setCoalesce(true)
|
||||
val output = HtmlDiffOutput()
|
||||
|
||||
try {
|
||||
// The XML processor expects `br` to be closed
|
||||
var currentContent =
|
||||
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
|
||||
var previousContent =
|
||||
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
|
||||
|
||||
for (i in 1 until sortedEdits.size) {
|
||||
processor.diff(previousContent, currentContent, output)
|
||||
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
|
||||
content = output.xml.toString()
|
||||
)
|
||||
val processor = OptimisticXMLProcessor()
|
||||
processor.setCoalesce(true)
|
||||
val output = HtmlDiffOutput()
|
||||
|
||||
try {
|
||||
// The XML processor expects `br` to be closed
|
||||
var currentContent =
|
||||
loader.load(sortedEdits[0].content.replace("<br>", "<br/>"))
|
||||
var previousContent =
|
||||
loader.load(sortedEdits[1].content.replace("<br>", "<br/>"))
|
||||
|
||||
for (i in 1 until sortedEdits.size) {
|
||||
processor.diff(previousContent, currentContent, output)
|
||||
sortedEdits[i - 1] = sortedEdits[i - 1].copy(
|
||||
content = output.xml.toString()
|
||||
)
|
||||
|
||||
if (i < sortedEdits.size - 1) {
|
||||
currentContent = previousContent
|
||||
previousContent = loader.load(
|
||||
sortedEdits[i + 1].content.replace(
|
||||
"<br>",
|
||||
"<br/>"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||
} catch (_: LoadingException) {
|
||||
// Something failed parsing the XML from the server. Rather than
|
||||
// show an error just return the sorted edits so the user can at
|
||||
// least visually scan the differences.
|
||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||
if (i < sortedEdits.size - 1) {
|
||||
currentContent = previousContent
|
||||
previousContent = loader.load(
|
||||
sortedEdits[i + 1].content.replace("<br>", "<br/>")
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ throwable ->
|
||||
_uiState.value = EditsUiState.Error(throwable)
|
||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||
} catch (_: LoadingException) {
|
||||
// Something failed parsing the XML from the server. Rather than
|
||||
// show an error just return the sorted edits so the user can at
|
||||
// least visually scan the differences.
|
||||
_uiState.value = EditsUiState.Success(sortedEdits)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.db
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
@ -70,8 +71,19 @@ data class AccountEntity(
|
|||
* that media previews are shown as well as downloaded.
|
||||
*/
|
||||
var mediaPreviewEnabled: Boolean = true,
|
||||
/**
|
||||
* ID of the last notification the user read on the Notification, list, and should be restored
|
||||
* to view when the user returns to the list.
|
||||
*
|
||||
* May not be the ID of the most recent notification if the user has scrolled down the list.
|
||||
*/
|
||||
var lastNotificationId: String = "0",
|
||||
var activeNotifications: String = "[]",
|
||||
/**
|
||||
* ID of the most recent Mastodon notification that Tusky has fetched to show as an
|
||||
* Android notification.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
var notificationMarkerId: String = "0",
|
||||
var emojis: List<Emoji> = emptyList(),
|
||||
var tabPreferences: List<TabData> = defaultTabs(),
|
||||
var notificationsFilter: String = "[\"follow_request\"]",
|
||||
|
@ -82,7 +94,13 @@ data class AccountEntity(
|
|||
var pushPubKey: String = "",
|
||||
var pushPrivKey: String = "",
|
||||
var pushAuth: String = "",
|
||||
var pushServerKey: String = ""
|
||||
var pushServerKey: String = "",
|
||||
|
||||
/**
|
||||
* ID of the status at the top of the visible list in the home timeline when the
|
||||
* user navigated away.
|
||||
*/
|
||||
var lastVisibleHomeTimelineStatusId: String? = null
|
||||
) {
|
||||
|
||||
val identifier: String
|
||||
|
|
|
@ -37,9 +37,11 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
|
||||
@Volatile
|
||||
var activeAccount: AccountEntity? = null
|
||||
private set
|
||||
|
||||
var accounts: MutableList<AccountEntity> = mutableListOf()
|
||||
private set
|
||||
|
||||
private val accountDao: AccountDao = db.accountDao()
|
||||
|
||||
init {
|
||||
|
@ -52,7 +54,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
|
|||
/**
|
||||
* Adds a new account and makes it the active account.
|
||||
* @param accessToken the access token for the new account
|
||||
* @param domain the domain of the accounts Mastodon instance
|
||||
* @param domain the domain of the account's Mastodon instance
|
||||
* @param clientId the oauth client id used to sign in the account
|
||||
* @param clientSecret the oauth client secret used to sign in the account
|
||||
* @param oauthScopes the oauth scopes granted to the account
|
||||
|
|
|
@ -16,8 +16,12 @@
|
|||
package com.keylesspalace.tusky.db;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.AutoMigration;
|
||||
import androidx.room.Database;
|
||||
import androidx.room.DeleteColumn;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.migration.AutoMigrationSpec;
|
||||
import androidx.room.migration.Migration;
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase;
|
||||
|
||||
|
@ -29,16 +33,29 @@ import java.io.File;
|
|||
/**
|
||||
* DB version & declare DAO
|
||||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 48)
|
||||
@Database(
|
||||
entities = {
|
||||
DraftEntity.class,
|
||||
AccountEntity.class,
|
||||
InstanceEntity.class,
|
||||
TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class,
|
||||
ConversationEntity.class
|
||||
},
|
||||
version = 51,
|
||||
autoMigrations = {
|
||||
@AutoMigration(from = 48, to = 49),
|
||||
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
|
||||
@AutoMigration(from = 50, to = 51)
|
||||
}
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
public abstract InstanceDao instanceDao();
|
||||
public abstract ConversationsDao conversationDao();
|
||||
public abstract TimelineDao timelineDao();
|
||||
public abstract DraftDao draftDao();
|
||||
@NonNull public abstract AccountDao accountDao();
|
||||
@NonNull public abstract InstanceDao instanceDao();
|
||||
@NonNull public abstract ConversationsDao conversationDao();
|
||||
@NonNull public abstract TimelineDao timelineDao();
|
||||
@NonNull public abstract DraftDao draftDao();
|
||||
|
||||
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
|
||||
@Override
|
||||
|
@ -370,7 +387,7 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
|
||||
private final File oldDraftDirectory;
|
||||
|
||||
public Migration25_26(File oldDraftDirectory) {
|
||||
public Migration25_26(@Nullable File oldDraftDirectory) {
|
||||
super(25, 26);
|
||||
this.oldDraftDirectory = oldDraftDirectory;
|
||||
}
|
||||
|
@ -653,4 +670,7 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
@DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications")
|
||||
static class MIGRATION_49_50 implements AutoMigrationSpec { }
|
||||
}
|
||||
|
|
|
@ -102,8 +102,8 @@ class Converters @Inject constructor(
|
|||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToAttachmentList(attachmentListJson: String?): ArrayList<Attachment>? {
|
||||
return gson.fromJson(attachmentListJson, object : TypeToken<ArrayList<Attachment>>() {}.type)
|
||||
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
|
||||
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
|
|
|
@ -29,12 +29,14 @@ import javax.inject.Singleton
|
|||
@Component(
|
||||
modules = [
|
||||
AppModule::class,
|
||||
CoroutineScopeModule::class,
|
||||
NetworkModule::class,
|
||||
AndroidSupportInjectionModule::class,
|
||||
ActivitiesModule::class,
|
||||
ServicesModule::class,
|
||||
BroadcastReceiverModule::class,
|
||||
ViewModelModule::class
|
||||
ViewModelModule::class,
|
||||
WorkerModule::class
|
||||
]
|
||||
)
|
||||
interface AppComponent {
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.receiver.NotificationBlockStateBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver
|
||||
import com.keylesspalace.tusky.receiver.UnifiedPushBroadcastReceiver
|
||||
import dagger.Module
|
||||
|
@ -28,9 +27,6 @@ abstract class BroadcastReceiverModule {
|
|||
@ContributesAndroidInjector
|
||||
abstract fun contributeSendStatusBroadcastReceiver(): SendStatusBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeNotificationClearBroadcastReceiver(): NotificationClearBroadcastReceiver
|
||||
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributeUnifiedPushBroadcastReceiver(): UnifiedPushBroadcastReceiver
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/**
|
||||
* Scope for potentially long-running tasks that should outlive the viewmodel that
|
||||
* started them. For example, if the API call to bookmark a status is taking a long
|
||||
* time, that call should not be cancelled because the user has navigated away from
|
||||
* the viewmodel that made the call.
|
||||
*
|
||||
* @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen
|
||||
*/
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
@Qualifier
|
||||
annotation class ApplicationScope
|
||||
|
||||
@Module
|
||||
class CoroutineScopeModule {
|
||||
@ApplicationScope
|
||||
@Provides
|
||||
fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import androidx.work.ListenableWorker
|
||||
import com.keylesspalace.tusky.worker.ChildWorkerFactory
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker
|
||||
import com.keylesspalace.tusky.worker.PruneCacheWorker
|
||||
import dagger.Binds
|
||||
import dagger.MapKey
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoMap
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@MapKey
|
||||
annotation class WorkerKey(val value: KClass<out ListenableWorker>)
|
||||
|
||||
@Module
|
||||
abstract class WorkerModule {
|
||||
@Binds
|
||||
@IntoMap
|
||||
@WorkerKey(NotificationWorker::class)
|
||||
internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@WorkerKey(PruneCacheWorker::class)
|
||||
internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory
|
||||
}
|
|
@ -16,7 +16,6 @@
|
|||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
|
||||
data class DeletedStatus(
|
||||
|
@ -25,7 +24,7 @@ data class DeletedStatus(
|
|||
@SerializedName("spoiler_text") val spoilerText: String,
|
||||
val visibility: Status.Visibility,
|
||||
val sensitive: Boolean,
|
||||
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>?,
|
||||
@SerializedName("media_attachments") val attachments: List<Attachment>?,
|
||||
val poll: Poll?,
|
||||
@SerializedName("created_at") val createdAt: Date,
|
||||
val language: String?
|
||||
|
|
|
@ -5,5 +5,5 @@ import com.google.gson.annotations.SerializedName
|
|||
data class FilterResult(
|
||||
val filter: Filter,
|
||||
@SerializedName("keyword_matches") val keywordMatches: List<String>?,
|
||||
@SerializedName("status_matches") val statusMatches: String?
|
||||
@SerializedName("status_matches") val statusMatches: List<String>?
|
||||
)
|
||||
|
|
|
@ -19,7 +19,6 @@ import android.text.SpannableStringBuilder
|
|||
import android.text.style.URLSpan
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import java.util.ArrayList
|
||||
import java.util.Date
|
||||
|
||||
data class Status(
|
||||
|
@ -42,7 +41,7 @@ data class Status(
|
|||
val sensitive: Boolean,
|
||||
@SerializedName("spoiler_text") val spoilerText: String,
|
||||
val visibility: Visibility,
|
||||
@SerializedName("media_attachments") val attachments: ArrayList<Attachment>,
|
||||
@SerializedName("media_attachments") val attachments: List<Attachment>,
|
||||
val mentions: List<Mention>,
|
||||
val tags: List<HashTag>?,
|
||||
val application: Application?,
|
||||
|
|
|
@ -21,15 +21,13 @@ import java.util.Date
|
|||
* Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
|
||||
*
|
||||
* @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
|
||||
* @param url The URL to your mastodon instance list for this hashtag.
|
||||
* (@param url The URL to your mastodon instance list for this hashtag.)
|
||||
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
|
||||
* @param following This is not listed in the APIs at the time of writing, but an instance is delivering it.
|
||||
* (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.)
|
||||
*/
|
||||
data class TrendingTag(
|
||||
val name: String,
|
||||
val url: String,
|
||||
val history: List<TrendingTagHistory>,
|
||||
val following: Boolean
|
||||
val history: List<TrendingTagHistory>
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,6 +26,7 @@ import android.view.MotionEvent
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.core.os.BundleCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
|
@ -92,7 +93,7 @@ class ViewImageFragment : ViewMediaFragment() {
|
|||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val arguments = this.requireArguments()
|
||||
val attachment = arguments.getParcelable<Attachment>(ARG_ATTACHMENT)
|
||||
val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java)
|
||||
this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION)
|
||||
val url: String?
|
||||
var description: String? = null
|
||||
|
|
|
@ -146,18 +146,28 @@ interface MastodonApi {
|
|||
): Response<Notification>
|
||||
|
||||
@GET("api/v1/markers")
|
||||
fun markersWithAuth(
|
||||
suspend fun markersWithAuth(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Query("timeline[]") timelines: List<String>
|
||||
): Single<Map<String, Marker>>
|
||||
): Map<String, Marker>
|
||||
|
||||
@GET("api/v1/notifications")
|
||||
fun notificationsWithAuth(
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/markers")
|
||||
suspend fun updateMarkersWithAuth(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Query("since_id") sinceId: String?
|
||||
): Single<List<Notification>>
|
||||
@Field("home[last_read_id]") homeLastReadId: String? = null,
|
||||
@Field("notifications[last_read_id]") notificationsLastReadId: String? = null
|
||||
): NetworkResult<Unit>
|
||||
|
||||
@GET("api/v1/notifications")
|
||||
suspend fun notificationsWithAuth(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
/** Return results immediately newer than this ID */
|
||||
@Query("min_id") minId: String?
|
||||
): Response<List<Notification>>
|
||||
|
||||
@POST("api/v1/notifications/clear")
|
||||
suspend fun clearNotifications(): Response<ResponseBody>
|
||||
|
@ -677,7 +687,7 @@ interface MastodonApi {
|
|||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/reports")
|
||||
fun report(
|
||||
suspend fun report(
|
||||
@Field("account_id") accountId: String,
|
||||
@Field("status_ids[]") statusIds: List<String>,
|
||||
@Field("comment") comment: String,
|
||||
|
@ -772,5 +782,5 @@ interface MastodonApi {
|
|||
suspend fun unfollowTag(@Path("name") name: String): NetworkResult<HashTag>
|
||||
|
||||
@GET("api/v1/trends/tags")
|
||||
suspend fun trendingTags(): Response<List<TrendingTag>>
|
||||
suspend fun trendingTags(): NetworkResult<List<TrendingTag>>
|
||||
}
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
/* 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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import dagger.android.AndroidInjection
|
||||
import javax.inject.Inject
|
||||
|
||||
class NotificationClearBroadcastReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidInjection.inject(this, context)
|
||||
|
||||
val accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1)
|
||||
|
||||
val account = accountManager.getAccountById(accountId)
|
||||
if (account != null) {
|
||||
account.activeNotifications = "[]"
|
||||
accountManager.saveAccount(account)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,11 +20,11 @@ import android.content.Intent
|
|||
import android.util.Log
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorker
|
||||
import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.worker.NotificationWorker
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
|
|
|
@ -16,6 +16,7 @@ import android.util.Log
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
|
@ -83,7 +84,7 @@ class SendStatusService : Service(), Injectable {
|
|||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (intent.hasExtra(KEY_STATUS)) {
|
||||
val statusToSend: StatusToSend = intent.getParcelableExtra(KEY_STATUS)
|
||||
val statusToSend: StatusToSend = IntentCompat.getParcelableExtra(intent, KEY_STATUS, StatusToSend::class.java)
|
||||
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
package com.keylesspalace.tusky.settings
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountPreferenceHandler(
|
||||
private val account: AccountEntity,
|
||||
class AccountPreferenceDataStore @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val dispatchEvent: (PreferenceChangedEvent) -> Unit
|
||||
private val eventHub: EventHub,
|
||||
@ApplicationScope private val externalScope: CoroutineScope
|
||||
) : PreferenceDataStore() {
|
||||
private val account: AccountEntity = accountManager.activeAccount!!
|
||||
|
||||
override fun getBoolean(key: String, defValue: Boolean): Boolean {
|
||||
return when (key) {
|
||||
|
@ -29,6 +35,8 @@ class AccountPreferenceHandler(
|
|||
|
||||
accountManager.saveAccount(account)
|
||||
|
||||
dispatchEvent(PreferenceChangedEvent(key))
|
||||
externalScope.launch {
|
||||
eventHub.dispatch(PreferenceChangedEvent(key))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -101,4 +101,7 @@ object PrefKeys {
|
|||
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
|
||||
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
|
||||
|
||||
/** UI text scaling factor, stored as float, 100 = 100% = no scaling */
|
||||
const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio"
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import androidx.preference.PreferenceCategory
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreference
|
||||
import com.keylesspalace.tusky.view.SliderPreference
|
||||
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
|
||||
|
||||
class PreferenceParent(
|
||||
|
@ -43,6 +44,15 @@ inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPicke
|
|||
return pref
|
||||
}
|
||||
|
||||
inline fun PreferenceParent.sliderPreference(
|
||||
builder: SliderPreference.() -> Unit
|
||||
): SliderPreference {
|
||||
val pref = SliderPreference(context)
|
||||
builder(pref)
|
||||
addPref(pref)
|
||||
return pref
|
||||
}
|
||||
|
||||
inline fun PreferenceParent.switchPreference(
|
||||
builder: SwitchPreference.() -> Unit
|
||||
): SwitchPreference {
|
||||
|
|
|
@ -84,11 +84,26 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
|
|||
imageDrawable?.let { drawable ->
|
||||
canvas.save()
|
||||
|
||||
val emojiSize = (paint.textSize * 1.1).toInt()
|
||||
drawable.setBounds(0, 0, emojiSize, emojiSize)
|
||||
// start with a width relative to the text size
|
||||
var emojiWidth = paint.textSize * 1.1
|
||||
|
||||
var transY = bottom - drawable.bounds.bottom
|
||||
transY -= paint.fontMetricsInt.descent / 2
|
||||
// calculate the height, keeping the aspect ratio correct
|
||||
val drawableWidth = drawable.intrinsicWidth
|
||||
val drawableHeight = drawable.intrinsicHeight
|
||||
var emojiHeight = emojiWidth / drawableWidth * drawableHeight
|
||||
|
||||
// how much vertical space there is draw the emoji
|
||||
val drawableSpace = (bottom - top).toDouble()
|
||||
|
||||
// in case the calculated height is bigger than the available space, scale the emoji down, preserving aspect ratio
|
||||
if (emojiHeight > drawableSpace) {
|
||||
emojiWidth *= drawableSpace / emojiHeight
|
||||
emojiHeight = drawableSpace
|
||||
}
|
||||
drawable.setBounds(0, 0, emojiWidth.toInt(), emojiHeight.toInt())
|
||||
|
||||
// vertically center the emoji in the line
|
||||
val transY = top + (drawableSpace / 2 - emojiHeight / 2)
|
||||
|
||||
canvas.translate(x, transY.toFloat())
|
||||
drawable.draw(canvas)
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* 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 <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* Returns a flow that mirrors the original flow, but filters out values that occur within
|
||||
* [timeout] of the previously emitted value. The first value is always emitted.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```kotlin
|
||||
* flow {
|
||||
* emit(1)
|
||||
* delay(90.milliseconds)
|
||||
* emit(2)
|
||||
* delay(90.milliseconds)
|
||||
* emit(3)
|
||||
* delay(1010.milliseconds)
|
||||
* emit(4)
|
||||
* delay(1010.milliseconds)
|
||||
* emit(5)
|
||||
* }.throttleFirst(1000.milliseconds)
|
||||
* ```
|
||||
*
|
||||
* produces the following emissions.
|
||||
*
|
||||
* ```text
|
||||
* 1, 4, 5
|
||||
* ```
|
||||
*
|
||||
* @see kotlinx.coroutines.flow.debounce(Duration)
|
||||
* @param timeout Emissions within this duration of the last emission are filtered
|
||||
* @param timeSource Used to measure elapsed time. Normally only overridden in tests
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun <T> Flow<T>.throttleFirst(
|
||||
timeout: Duration,
|
||||
timeSource: TimeSource = TimeSource.Monotonic
|
||||
) = flow {
|
||||
var marker: TimeMark? = null
|
||||
collect {
|
||||
if (marker == null || marker!!.elapsedNow() >= timeout) {
|
||||
emit(it)
|
||||
marker = timeSource.markNow()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,25 +2,27 @@
|
|||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sign
|
||||
|
||||
val shortLetters = arrayOf(' ', 'K', 'M', 'B', 'T', 'P', 'E')
|
||||
private val numberFormatter: NumberFormat = NumberFormat.getInstance()
|
||||
private val ln_1k = ln(1000.0)
|
||||
|
||||
fun shortNumber(number: Number): String {
|
||||
val numberAsDouble = number.toDouble()
|
||||
val nonNegativeValue = abs(numberAsDouble)
|
||||
var sign = ""
|
||||
if (numberAsDouble.sign < 0) { sign = "-" }
|
||||
val value = floor(log10(nonNegativeValue)).toInt()
|
||||
val base = value / 3
|
||||
if (value >= 3 && base < shortLetters.size) {
|
||||
return DecimalFormat("$sign#0.0").format(nonNegativeValue / 10.0.pow((base * 3).toDouble())) + shortLetters[base]
|
||||
} else {
|
||||
return DecimalFormat("$sign#,##0").format(nonNegativeValue)
|
||||
}
|
||||
/**
|
||||
* Format numbers according to the current locale. Numbers < min have
|
||||
* separators (',', '.', etc) inserted according to the locale.
|
||||
*
|
||||
* Numbers >= min are scaled down to that by multiples of 1,000, and
|
||||
* a suffix appropriate to the scaling is appended.
|
||||
*/
|
||||
fun formatNumber(num: Long, min: Int = 100000): String {
|
||||
val absNum = abs(num)
|
||||
if (absNum < min) return numberFormatter.format(num)
|
||||
|
||||
val exp = (ln(absNum.toDouble()) / ln_1k).toInt()
|
||||
|
||||
// Suffixes here are locale-agnostic
|
||||
return String.format("%.1f%c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
|
||||
}
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.arch.core.util.Function
|
||||
|
||||
/**
|
||||
* This list implementation can help to keep two lists in sync - like real models and view models.
|
||||
*
|
||||
* Every operation on the main list triggers update of the supplementary list (but not vice versa).
|
||||
*
|
||||
* This makes sure that the main list is always the source of truth.
|
||||
*
|
||||
* Main list is projected to the supplementary list by the passed mapper function.
|
||||
*
|
||||
* Paired list is newer actually exposed and clients are provided with `getPairedCopy()`,
|
||||
* `getPairedItem()` and `setPairedItem()`. This prevents modifications of the
|
||||
* supplementary list size so lists are always have the same length.
|
||||
*
|
||||
* This implementation will not try to recover from exceptional cases so lists may be out of sync
|
||||
* after the exception.
|
||||
*
|
||||
* It is most useful with immutable data because we cannot track changes inside stored objects.
|
||||
*
|
||||
* @param T type of elements in the main list
|
||||
* @param V type of elements in supplementary list
|
||||
* @param mapper Function, which will be used to translate items from the main list to the
|
||||
* supplementary one.
|
||||
* @constructor
|
||||
*/
|
||||
class PairedList<T, V> (private val mapper: Function<T, out V>) : AbstractMutableList<T>() {
|
||||
private val main: MutableList<T> = ArrayList()
|
||||
private val synced: MutableList<V> = ArrayList()
|
||||
|
||||
val pairedCopy: List<V>
|
||||
get() = ArrayList(synced)
|
||||
|
||||
fun getPairedItem(index: Int): V {
|
||||
return synced[index]
|
||||
}
|
||||
|
||||
fun getPairedItemOrNull(index: Int): V? {
|
||||
return synced.getOrNull(index)
|
||||
}
|
||||
|
||||
fun setPairedItem(index: Int, element: V) {
|
||||
synced[index] = element
|
||||
}
|
||||
|
||||
override fun get(index: Int): T {
|
||||
return main[index]
|
||||
}
|
||||
|
||||
override fun set(index: Int, element: T): T {
|
||||
synced[index] = mapper.apply(element)
|
||||
return main.set(index, element)
|
||||
}
|
||||
|
||||
override fun add(element: T): Boolean {
|
||||
synced.add(mapper.apply(element))
|
||||
return main.add(element)
|
||||
}
|
||||
|
||||
override fun add(index: Int, element: T) {
|
||||
synced.add(index, mapper.apply(element))
|
||||
main.add(index, element)
|
||||
}
|
||||
|
||||
override fun removeAt(index: Int): T {
|
||||
synced.removeAt(index)
|
||||
return main.removeAt(index)
|
||||
}
|
||||
|
||||
override val size: Int
|
||||
get() = main.size
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.R
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
|
||||
|
@ -24,3 +27,16 @@ fun Throwable.getServerErrorMessage(): String? {
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** @return A drawable resource to accompany the error message for this throwable */
|
||||
fun Throwable.getDrawableRes(): Int = when (this) {
|
||||
is IOException -> R.drawable.elephant_offline
|
||||
is HttpException -> R.drawable.elephant_offline
|
||||
else -> R.drawable.elephant_error
|
||||
}
|
||||
|
||||
/** @return A string error message for this throwable */
|
||||
fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) {
|
||||
is IOException -> context.getString(R.string.error_network)
|
||||
else -> context.getString(R.string.error_generic)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,6 @@ import com.keylesspalace.tusky.viewdata.NotificationViewData
|
|||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.keylesspalace.tusky.viewdata.TrendingViewData
|
||||
|
||||
@JvmName("statusToViewData")
|
||||
fun Status.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
|
@ -56,7 +55,6 @@ fun Status.toViewData(
|
|||
)
|
||||
}
|
||||
|
||||
@JvmName("notificationToViewData")
|
||||
fun Notification.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
|
@ -71,9 +69,20 @@ fun Notification.toViewData(
|
|||
)
|
||||
}
|
||||
|
||||
@JvmName("tagToViewData")
|
||||
fun TrendingTag.toViewData(): TrendingViewData.Tag {
|
||||
return TrendingViewData.Tag(
|
||||
tag = this
|
||||
)
|
||||
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
|
||||
val maxTrendingValue = flatMap { tag -> tag.history }
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
.maxOrNull() ?: 1
|
||||
|
||||
return map { tag ->
|
||||
|
||||
val reversedHistory = tag.history.asReversed()
|
||||
|
||||
TrendingViewData.Tag(
|
||||
name = tag.name,
|
||||
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
|
||||
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
|
||||
maxTrendingValue = maxTrendingValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ package com.keylesspalace.tusky.util
|
|||
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
|
||||
|
@ -66,3 +67,14 @@ fun ViewPager2.reduceSwipeSensitivity() {
|
|||
Log.w("reduceSwipeSensitivity", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TextViews with an ancestor RecyclerView can forget that they are selectable. Toggling
|
||||
* calls to [TextView.setTextIsSelectable] fixes this.
|
||||
*
|
||||
* @see https://issuetracker.google.com/issues/37095917
|
||||
*/
|
||||
fun TextView.fixTextSelection() {
|
||||
setTextIsSelectable(false)
|
||||
post { setTextIsSelectable(true) }
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.keylesspalace.tusky.view
|
||||
|
||||
import android.content.Context
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
|
@ -12,6 +13,8 @@ import androidx.annotation.StringRes
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ViewBackgroundMessageBinding
|
||||
import com.keylesspalace.tusky.util.addDrawables
|
||||
import com.keylesspalace.tusky.util.getDrawableRes
|
||||
import com.keylesspalace.tusky.util.getErrorString
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
|
||||
/**
|
||||
|
@ -34,16 +37,27 @@ class BackgroundMessageView @JvmOverloads constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun setup(throwable: Throwable, listener: ((v: View) -> Unit)? = null) {
|
||||
setup(throwable.getDrawableRes(), throwable.getErrorString(context), listener)
|
||||
}
|
||||
|
||||
fun setup(
|
||||
@DrawableRes imageRes: Int,
|
||||
@StringRes messageRes: Int,
|
||||
clickListener: ((v: View) -> Unit)? = null
|
||||
) = setup(imageRes, context.getString(messageRes), clickListener)
|
||||
|
||||
/**
|
||||
* Setup image, message and button.
|
||||
* If [clickListener] is `null` then the button will be hidden.
|
||||
*/
|
||||
fun setup(
|
||||
@DrawableRes imageRes: Int,
|
||||
@StringRes messageRes: Int,
|
||||
message: String,
|
||||
clickListener: ((v: View) -> Unit)? = null
|
||||
) {
|
||||
binding.messageTextView.setText(messageRes)
|
||||
binding.messageTextView.text = message
|
||||
binding.messageTextView.movementMethod = LinkMovementMethod.getInstance()
|
||||
binding.imageView.setImageResource(imageRes)
|
||||
binding.button.setOnClickListener(clickListener)
|
||||
binding.button.visible(clickListener != null)
|
||||
|
|
|
@ -114,6 +114,14 @@ class ClickableSpanTextView @JvmOverloads constructor(
|
|||
lengthAfter: Int
|
||||
) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
|
||||
// TextView tries to optimise the layout process, and will not perform a layout if the
|
||||
// new text takes the same area as the old text (see TextView.checkForRelayout()). This
|
||||
// can result in statuses using the wrong clickable areas as they are never remeasured.
|
||||
// (https://github.com/tuskyapp/Tusky/issues/3596). Force a layout pass to ensure that
|
||||
// the spans are measured correctly.
|
||||
if (!isInLayout) requestLayout()
|
||||
|
||||
doOnLayout { measureSpans() }
|
||||
}
|
||||
|
||||
|
@ -126,11 +134,11 @@ class ClickableSpanTextView @JvmOverloads constructor(
|
|||
* If the span runs over multiple lines there will be two Rects per line.
|
||||
*/
|
||||
private fun measureSpans() {
|
||||
val spannedText = text as? Spanned ?: return
|
||||
|
||||
spanRects.clear()
|
||||
delegateRects.clear()
|
||||
|
||||
val spannedText = text as? Spanned ?: return
|
||||
|
||||
// The goal is to record all the [Rect]s associated with a span with the same fidelity
|
||||
// that the user sees when they highlight text in the view to select it.
|
||||
//
|
||||
|
@ -358,7 +366,7 @@ class ClickableSpanTextView @JvmOverloads constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "LinkTextView"
|
||||
const val TAG = "ClickableSpanTextView"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,10 +22,9 @@ import android.graphics.Path
|
|||
import android.graphics.PathMeasure
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import com.keylesspalace.tusky.R
|
||||
import kotlin.math.max
|
||||
|
@ -33,9 +32,8 @@ import kotlin.math.max
|
|||
class GraphView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
defStyleRes: Int = 0
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
@get:ColorInt
|
||||
@ColorInt
|
||||
var primaryLineColor = 0
|
||||
|
@ -55,7 +53,7 @@ class GraphView @JvmOverloads constructor(
|
|||
@ColorInt
|
||||
var metaColor = 0
|
||||
|
||||
var proportionalTrending = false
|
||||
private var proportionalTrending = false
|
||||
|
||||
private lateinit var primaryLinePaint: Paint
|
||||
private lateinit var secondaryLinePaint: Paint
|
||||
|
@ -129,16 +127,14 @@ class GraphView @JvmOverloads constructor(
|
|||
|
||||
private fun initFromXML(attr: AttributeSet?) {
|
||||
context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a ->
|
||||
primaryLineColor = ContextCompat.getColor(
|
||||
context,
|
||||
primaryLineColor = context.getColor(
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_primaryLineColor,
|
||||
R.color.tusky_blue
|
||||
)
|
||||
)
|
||||
|
||||
secondaryLineColor = ContextCompat.getColor(
|
||||
context,
|
||||
secondaryLineColor = context.getColor(
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_secondaryLineColor,
|
||||
R.color.tusky_red
|
||||
|
@ -150,16 +146,14 @@ class GraphView @JvmOverloads constructor(
|
|||
R.dimen.graph_line_thickness
|
||||
).toFloat()
|
||||
|
||||
graphColor = ContextCompat.getColor(
|
||||
context,
|
||||
graphColor = context.getColor(
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_graphColor,
|
||||
R.color.colorBackground
|
||||
)
|
||||
)
|
||||
|
||||
metaColor = ContextCompat.getColor(
|
||||
context,
|
||||
metaColor = context.getColor(
|
||||
a.getResourceId(
|
||||
R.styleable.GraphView_metaColor,
|
||||
R.color.dividerColor
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue