Merge origin/develop

This commit is contained in:
Weblate 2020-10-05 15:10:44 +02:00
commit 4eb56b713c
258 changed files with 11979 additions and 3325 deletions

View File

@ -10,7 +10,7 @@ before_script:
- yes | sdkmanager "ndk-bundle"
- export ANDROID_NDK_ROOT=$ANDROID_HOME/ndk-bundle
- export ANDROID_NDK_HOME=$ANDROID_NDK_ROOT
- sed -i "s/blue {}//" app/build.gradle
- sed -i "s/blue/\/\/blue/" app/build.gradle
- sed -i "s/\/\/abortOnError/abortOnError/" app/build.gradle
# - sed -i "s/debug {}//" app/build.gradle
before_cache:
@ -28,7 +28,7 @@ after_success:
- $ANDROID_HOME/build-tools/28.0.3/apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass "pass:android" --key-pass "pass:android" --in $(find -name "*-green-release-unsigned.apk") --out husky-green-release.apk
- wget https://raw.githubusercontent.com/FWGS/uploadtool/master/upload.sh
- chmod +x upload.sh
- ./upload.sh husky-green-debug.apk husky-green-release.apk
- GITHUB_TOKEN=$GH_TOKEN ./upload.sh husky-green-debug.apk husky-green-release.apk
branches:
except:
# Do not build tags that we create when we upload to GitHub Releases

View File

@ -27,7 +27,7 @@ Tusky is quote, unquote, `... a beautiful Android client for [Mastodon](https://
If you have any bug reports, feature requests or questions please open an issue or send us a post at [Husky@enigmatic.observer](https://enigmatic.observer/users/Husky)!
For translating Tusky into your language, visit https://weblate.tusky.app/.\
For translating Tusky into your language, visit https://weblate.tusky.app.
For translating Husky, visit https://l10n.mentality.rip.
### Head of development

View File

@ -113,41 +113,40 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
ext.lifecycleVersion = "2.2.0"
ext.roomVersion = '2.2.5'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.7.2'
ext.okhttpVersion = '4.8.1'
ext.glideVersion = '4.11.0'
ext.daggerVersion = '2.27'
ext.materialdrawerVersion = '8.1.2'
ext.daggerVersion = '2.28.3'
ext.materialdrawerVersion = '8.1.4'
// if libraries are changed here, they should also be changed in LicenseActivity
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "androidx.core:core-ktx:1.3.0"
implementation "androidx.appcompat:appcompat:1.2.0-rc01"
implementation "androidx.core:core-ktx:1.3.1"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.exifinterface:exifinterface:1.2.0"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.0.0"
implementation "androidx.emoji:emoji:1.1.0-rc01"
implementation "androidx.emoji:emoji-appcompat:1.1.0-rc01"
implementation "androidx.emoji:emoji-bundled:1.1.0-rc01"
implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.emoji:emoji-bundled:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.paging:paging-runtime-ktx:2.1.1"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.3.4"
implementation "androidx.work:work-runtime:2.4.0"
implementation "androidx.room:room-runtime:$roomVersion"
implementation "androidx.room:room-rxjava2:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.1.0"
implementation "com.google.android.material:material:1.2.0"
implementation 'com.google.android:flexbox:2.0.1'
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
@ -178,9 +177,9 @@ dependencies {
implementation "com.github.connyduck:sparkbutton:4.0.0"
implementation 'com.github.piasy:BigImageViewer:1.6.5'
implementation 'com.github.piasy:GlideImageLoader:1.6.5'
implementation 'com.github.piasy:GlideImageViewFactory:1.6.5'
implementation 'com.github.piasy:BigImageViewer:1.7.0'
implementation 'com.github.piasy:GlideImageLoader:1.7.0'
implementation 'com.github.piasy:GlideImageViewFactory:1.7.0'
implementation "com.mikepenz:materialdrawer:$materialdrawerVersion"
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
@ -197,7 +196,7 @@ dependencies {
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0"
androidTestImplementation "android.arch.persistence.room:testing:1.1.1"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
debugImplementation "im.dino:dbinspector:4.0.0@aar"

View File

@ -55,6 +55,9 @@
public static *** v(...);
public static *** i(...);
}
-assumenosideeffects class java.lang.String {
public static java.lang.String format(...);
}
# remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
@ -62,8 +65,3 @@
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String);
}
# without this emoji font downloading fails with AbstractMethodError
-keep class * extends android.os.AsyncTask {
public *;
}

View File

@ -0,0 +1,897 @@
{
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "0a5f8f196d357a01b8b571098ea32431",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "formattingSyntax",
"columnName": "formattingSyntax",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "markdownMode",
"columnName": "markdownMode",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsStreamingEnabled` 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, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationsChatMessages` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsStreamingEnabled",
"columnName": "notificationsStreamingEnabled",
"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": "notificationsEmojiReactions",
"columnName": "notificationsEmojiReactions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsChatMessages",
"columnName": "notificationsChatMessages",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultFormattingSyntax",
"columnName": "defaultFormattingSyntax",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"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, `version` TEXT, `chatLimit` 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": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatLimit",
"columnName": "chatLimit",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `pleroma` 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": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "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": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pleroma",
"columnName": "pleroma",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"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": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.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.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ChatEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))",
"fields": [
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chatId",
"columnName": "chatId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"localId",
"chatId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ChatMessageEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))",
"fields": [
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatId",
"columnName": "chatId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachment",
"columnName": "attachment",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"localId",
"messageId"
],
"autoGenerate": false
},
"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, '0a5f8f196d357a01b8b571098ea32431')"
]
}
}

View File

@ -78,46 +78,39 @@ class TimelineDAOTest {
fun cleanup() {
val now = System.currentTimeMillis()
val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000
val oldByThisAccount = makeStatus(
val oldThisAccount = makeStatus(
statusId = 5,
createdAt = oldDate
)
val oldByAnotherAccount = makeStatus(
val oldAnotherAccount = makeStatus(
statusId = 10,
createdAt = oldDate,
authorServerId = "100"
accountId = 2
)
val oldForAnotherAccount = makeStatus(
accountId = 2,
statusId = 20,
authorServerId = "200",
createdAt = oldDate
)
val recentByThisAccount = makeStatus(
val recentThisAccount = makeStatus(
statusId = 30,
createdAt = System.currentTimeMillis()
)
val recentByAnotherAccount = makeStatus(
val recentAnotherAccount = makeStatus(
statusId = 60,
createdAt = System.currentTimeMillis(),
authorServerId = "200"
)
accountId = 2
)
for ((status, author, reblogAuthor) in listOf(oldByThisAccount, oldByAnotherAccount,
oldForAnotherAccount, recentByThisAccount, recentByAnotherAccount)) {
for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) {
timelineDao.insertInTransaction(status, author, reblogAuthor)
}
timelineDao.cleanup(1, "20", now - TimelineRepository.CLEANUP_INTERVAL)
timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL)
assertEquals(
listOf(recentByAnotherAccount, recentByThisAccount, oldByThisAccount),
listOf(recentThisAccount),
timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet()
.map { it.toTriple() }
)
assertEquals(
listOf(oldForAnotherAccount),
listOf(recentAnotherAccount),
timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet()
.map { it.toTriple() }
)
@ -217,7 +210,8 @@ class TimelineDAOTest {
application = "application$accountId",
reblogServerId = if (reblog) (statusId * 100).toString() else null,
reblogAccountId = reblogAuthor?.serverId,
poll = null
poll = null,
muted = false
)
return Triple(status, author, reblogAuthor)
}
@ -246,7 +240,8 @@ class TimelineDAOTest {
application = null,
reblogServerId = null,
reblogAccountId = null,
poll = null
poll = null,
muted = false
)
}

View File

@ -22,7 +22,7 @@
<string name="about_project_site">موقع المشروع :\n
https://husky.fwgs.ru</string>
https://huskyapp.dev</string>

View File

@ -0,0 +1,14 @@
<resources>
<string name="action_login">ⵇⵇⴻⵏ ⵖⴻⵔ ⵎⴰⵚⵟⵓⴷⵓⵏ</string>
<string name="add_account_description">ⵔⵏⵓ ⵢⵉⵡⴻⵏ ⵓⵎⵉⴹⴰⵏ ⴰⵎⴰⵢⵏⵓⵝ ⵎⴰⵚⵟⵓⴷⵓⵏ</string>
</resources>

View File

@ -0,0 +1,62 @@
<resources>
<string name="license_description">টাস্কি নিম্নলিখিত ওপেন সোর্স প্রকল্প থেকে কোড এবং সম্পদ রয়েছে:</string>
<string name="restart_emoji">এই পরিবর্তনগুলি প্রয়োগ করার জন্য আপনাকে টাস্কি পুনরায় চালু করতে হবে</string>
<string name="about_tusky_account">টাস্কির প্রোফাইল</string>
<string name="about_tusky_license">টাস্কি মুক্ত এবং ওপেন সোর্স সফ্টওয়্যার। এটি GNU জেনারেল পাবলিক লাইসেন্স সংস্করণ 3 এর অধীনে লাইসেন্সযুক্ত। আপনি এখানে লাইসেন্স দেখতে পারেন: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_tusky_version">টাস্কি %s</string>
<string name="about_powered_by_tusky">টাস্কি দ্বারা চালিত</string>
<string name="about_project_site">প্রকল্প ওয়েবসাইট:
\nhttps://huskyapp.dev</string>
<string name="about_bug_feature_request_site">বাগ রিপোর্ট এবং বৈশিষ্ট্য অনুরোধ:
\nhttps://git.mentality.rip/FWGS/Husky/issues</string>
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
<string name="action_login">মাস্টোডনের সঙ্গে লগইন করো</string>
<string name="warning_scheduling_interval">মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে।</string>
<string name="dialog_whats_an_instance">"কোনও উদাহরণের ঠিকানা বা ডোমেন এখানে প্রবেশ করা যেতে পারে যেমন shitposter.club, blob.cat, expired.mentality.rip, এবং &lt;a href=\"https://fediverse.network/pleroma?count=peers\"&gt; আরও! &lt;/a&gt;
\n
\nআপনার যদি এখনো অ্যাকাউন্ট না থাকে তবে আপনি যে ইনস্ট্যান্সটিতে যোগ দিতে চান সেটির নামটি প্রবেশ করতে এবং সেখানে একটি অ্যাকাউন্ট তৈরি করতে পারেন।
\n
\nএকটি ইনস্ট্যান্স একটি একক স্থান যেখানে আপনার অ্যাকাউন্ট হোস্ট করা হয়, তবে আপনি সহজেই যোগাযোগ করতে পারেন এবং অন্যান্য ক্ষেত্রে যেমন আপনি একই সাইটে ছিলেন তা অনুসরণ করতে পারেন।
\n
\nআরো তথ্য &lt;a href=\"https://joinmastodon.org\"&gt; joinmastodon.org &lt;/a&gt; এ পাওয়া যেতে পারে। "<a href="https://fediverse.network/pleroma?count=peers">more!</a>
\n\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to
join and create an account there.\n\nAn instance is a single place where your account is
hosted, but you can easily communicate with and follow folks on other instances as though
you were on the same site.
\n\nMore info can be found at <a href="https://joinmastodon.org">joinmastodon.org</a>.
</string>
</resources>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">প্রকল্প ওয়েবসাইট:
\nhttps://husky.fwgs.ru</string>
\nhttps://huskyapp.dev</string>
@ -33,20 +33,23 @@
<string name="action_login">্যাস্টোডোন সঙ্গে লগইন করুন</string>
<string name="action_login">াস্টোডনের সঙ্গে লগইন করো</string>
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
<string name="warning_scheduling_interval">মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে।</string>
<string name="dialog_whats_an_instance">কোনও উদাহরণের ঠিকানা বা ডোমেন এখানে প্রবেশ করা যেতে পারে যেমন shitposter.club, blob.cat, expired.mentality.rip, এবং <a href="https://fediverse.network/pleroma?count=peers"> আরও! </a>
\n
\nআপনার যদি এখনো অ্যাকাউন্ট না থাকে তবে আপনি যে ইনস্ট্যান্সটিতে যোগ দিতে চান সেটির নামটি প্রবেশ করতে এবং সেখানে একটি অ্যাকাউন্ট তৈরি করতে পারেন।
\n
\nএকটি ইনস্ট্যান্স একটি একক স্থান যেখানে আপনার অ্যাকাউন্ট হোস্ট করা হয়, তবে আপনি সহজেই যোগাযোগ করতে পারেন এবং অন্যান্য ক্ষেত্রে যেমন আপনি একই সাইটে ছিলেন তা অনুসরণ করতে পারেন।
\n
<string name="dialog_whats_an_instance">কোনও উদাহরণের ঠিকানা বা ডোমেন এখানে প্রবেশ করা যেতে পারে যেমন shitposter.club, blob.cat, expired.mentality.rip, এবং <a href="https://fediverse.network/pleroma?count=peers"> আরও! </a>
\n
\nআপনার যদি এখনো অ্যাকাউন্ট না থাকে তবে আপনি যে ইনস্ট্যান্সটিতে যোগ দিতে চান সেটির নামটি প্রবেশ করতে এবং সেখানে একটি অ্যাকাউন্ট তৈরি করতে পারেন।
\n
\nএকটি ইনস্ট্যান্স একটি একক স্থান যেখানে আপনার অ্যাকাউন্ট হোস্ট করা হয়, তবে আপনি সহজেই যোগাযোগ করতে পারেন এবং অন্যান্য ক্ষেত্রে যেমন আপনি একই সাইটে ছিলেন তা অনুসরণ করতে পারেন।
\n
\nআরো তথ্য <a href="https://joinmastodon.org"> joinmastodon.org </a> এ পাওয়া যেতে পারে। </string>

View File

@ -25,7 +25,7 @@
<string name="about_project_site">
Lloc web del projecte:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -16,12 +16,15 @@
<string name="license_description">Husky obsahuje kód a zdroje z následujících otevřených projektů:</string>
<string name="about_powered_by_tusky">Powered by Husky</string>
<string name="about_project_site"> Webová stránka projektu:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -39,6 +42,9 @@
<string name="add_account_description">Přidat nový účet Pleroma</string>
<string name="warning_scheduling_interval">Pleroma neumožňuje pracovat s intervalem menším než 5 minut.</string>

View File

@ -18,7 +18,7 @@
<string name="about_project_site"> Gwefan y prosiect:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -1,6 +1,6 @@
<resources>
<string name="about_tusky_license">Husky ist freie eine quelloffene Software. Es ist lizenziert unter der GNU General Public License Version 3. Du kannst dir die Lizenz hier anschauen: https://www.gnu.org/licenses/gpl-3.0.de.html</string>
<string name="about_tusky_license">Husky ist freie und quelloffene Software. Es ist lizenziert unter der GNU General Public License Version 3. Du kannst dir die Lizenz hier anschauen: https://www.gnu.org/licenses/gpl-3.0.de.html</string>
<string name="about_tusky_account">Huskys Profil</string>
@ -12,7 +12,7 @@
<string name="license_description">Husky enthält Code und Inhalte von den folgenden Open-Source-Projekten:</string>
<string name="about_tusky_version">Husky %s</string>
<string name="about_tusky_version">test %s</string>
<string name="about_powered_by_tusky">Angetrieben durch Husky</string>
@ -21,8 +21,8 @@
<string name="about_project_site">Website des Projekts:
\n https://husky.fwgs.ru</string>
<string name="about_project_site">Website des Projekts:
\n https://huskyapp.dev</string>

View File

@ -24,7 +24,7 @@
<string name="about_project_site"> Paĝaro de projekto:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -24,7 +24,7 @@
<string name="about_project_site"> Sitio del proyecto:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -24,7 +24,7 @@
<string name="about_project_site"> Proiektuaren gunea:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -6,10 +6,10 @@
<string name="about_tusky_account">نمایهٔ تاسکی</string>
<string name="restart_emoji">شما برای اعمال این تغییرات به شروع مجدد برنامه نیاز دارید</string>
<string name="restart_emoji">برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید</string>
<string name="license_description">تاسکی شامل کد و دارایی‌هایی از پروژه‌های آزاد زیر است:</string>
<string name="license_description">تاسکی کد و دارایی‌هایی از پروژه‌های نرم‌افزار آزاد زیر دارد:</string>
<string name="about_tusky_version">تاسکی %s</string>
@ -21,13 +21,13 @@
<string name="about_project_site">پایگاه وب پروژه:
\n https://husky.fwgs.ru</string>
<string name="about_project_site">پایگاه وب پروژه :
\n https://huskyapp.dev</string>
<string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگی‌ها:
<string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگی‌ها:
\n https://git.mentality.rip/FWGS/Husky/issues</string>
@ -36,7 +36,7 @@
<string name="action_login">ورود با ماستودون</string>
<string name="add_account_description">افزودن حساب جدید ماستودون</string>
<string name="add_account_description">افزودن حساب ماستودون جدید</string>
<string name="warning_scheduling_interval">ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد.</string>

View File

@ -24,7 +24,7 @@
<string name="about_project_site"> Site du projet :\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -43,7 +43,7 @@
<string name="add_account_description">Ajouter un nouveau compte Pleroma</string>
<string name="warning_scheduling_interval">Lintervalle minimum de planification sur Pleroma est de 5 minutes.</string>
<string name="warning_scheduling_interval">Lintervalle minimum de planification sur Pleroma est de5 minutes.</string>

View File

@ -0,0 +1,56 @@
<resources>
<string name="about_tusky_account">Próifíl Husky</string>
<string name="about_tusky_license">Is bogearraí foinse oscailte agus saor in aisce é Husky. Tá sé ceadúnaithe faoi Leagan 3. Ceadúnas Poiblí Ginearálta GNU 3. Is féidir leat an ceadúnas a fheiceáil anseo: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">Cumhachtaithe ag Husky</string>
<string name="about_tusky_version">Husky %s</string>
<string name="restart_emoji">Beidh ort Husky a atosú chun na hathruithe seo a chur i bhfeidhm</string>
<string name="license_description">Tá cód agus sócmhainní ó na tionscadail foinse oscailte seo a leanas i Husky:</string>
<string name="about_project_site">Suíomh Gréasáin an tionscadail:
\n https://huskyapp.dev</string>
<string name="about_bug_feature_request_site">Tuarascálacha ar fhabhtanna &amp; iarratais ar ghnéithe:
\n https://git.mentality.rip/FWGS/Husky/issues</string>
<string name="action_login">Logáil isteach le Pleroma</string>
<string name="add_account_description">Cuir Cuntas Pleroma nua leis</string>
<string name="warning_scheduling_interval">Tá eatramh sceidealaithe íosta 5 nóiméad ag Pleroma.</string>
<string name="dialog_whats_an_instance">Is féidir seoladh nó fearann aon cháis a iontráil anseo, mar shampla shitposter.club, blob.cat, expired.mentality.rip, agus <a href="https://fediverse.network/pleroma?count=peers"> níos mó! </a>
\n
\nMura bhfuil cuntas agat fós, is féidir leat ainm an cháis ar mhaith leat a bheith páirteach ann agus cuntas a chruthú ann.
\n
\nIs áit amháin é sampla ina ndéantar do chuntas a óstáil, ach is féidir leat cumarsáid a dhéanamh go héasca le daoine eile agus iad a leanúint ar chásanna eile mar a bheadh tú ar an suíomh céanna.
\n
\nIs féidir tuilleadh faisnéise a fháil ag <a href="https://joinmastodon.org"> joinmastodon.org </a>. </string>
</resources>

View File

@ -0,0 +1,11 @@
<resources>
<string name="action_login">Clàraich a-steach le Pleroma</string>
</resources>

View File

@ -1,11 +1,46 @@
<resources>
<string name="license_description">टस्की में निम्नलिखित ओपन सोर्स परियोजनाओं से कोड और संपत्ति हैं:</string>
<string name="restart_emoji">इन परिवर्तनों को लागू करने के लिए आपको टस्की को पुनः आरंभ करना होगा</string>
<string name="about_tusky_account">टस्की की प्रोफाइल</string>
<string name="about_tusky_license">टस्की स्वतंत्र और ओपन-सोर्स सॉफ्टवेयर है। यह GNU जनरल पब्लिक लाइसेंस संस्करण 3 के तहत लाइसेंस प्राप्त है। आप लाइसेंस यहां देख सकते हैं: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">टस्की द्वारा संचालित</string>
<string name="about_tusky_version">टस्की %s</string>
<string name="about_project_site">परियोजना की वेबसाइट:
\n https://huskyapp.dev</string>
<string name="about_bug_feature_request_site">बग रिपोर्ट और सुविधा अनुरोध:
\n https://git.mentality.rip/FWGS/Husky/issues</string>
<string name="action_login">हिंदी</string>
<string name="warning_scheduling_interval">मास्टोडन का न्यूनतम शेड्यूलिंग अंतराल 5 मिनट है।</string>
<string name="add_account_description">नया मास्टोडन खाता जोड़ें</string>
</resources>

View File

@ -15,14 +15,14 @@
<string name="about_tusky_license">Husky ingyenes és nyílt forráskódú szoftver. A GNU General Public License Version 3 érvényes rá, amit itt tekinthetsz meg: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">Husky által hatjva</string>
<string name="about_powered_by_tusky">Husky által hajtva</string>
<string name="about_project_site"> Projekt honlapja:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -34,7 +34,7 @@
<string name="action_login">Bejelentkezés Pleroma-al</string>
<string name="action_login">Bejelentkezés Pleroma-nal</string>
<string name="add_account_description">Új Pleroma fiók hozzáadása</string>
@ -46,11 +46,11 @@
<string name="dialog_whats_an_instance">Bármely szerver címét beírhatod ide, mint shitposter.club, blob.cat, expired.mentality.rip, és <a href="https://fediverse.network/pleroma?count=peers">mások!</a>
\n
\nHa még nincs fiókod, beírhatod a címét ide annak a szervernek amelyhez csatlakoznál, majd azon létrehozhatsz egy fiókot.
\n
\n
\nHa még nincs fiókod, beírhatod a címét ide annak a szervernek amelyhez csatlakoznál, majd azon létrehozhatsz egy fiókot.
\n
\nA szerver az a hely ahol a fiókadataidat tárolják, de ettől még ugyanúgy kommunikálhatsz más szervereken lévő emberekkel, mintha ugyanazon az oldalon lennétek.
\n
\n
\nTöbb információt találhatsz itt: <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">Vefsvæði verkefnisins:
\n https://husky.fwgs.ru</string>
\n https://huskyapp.dev</string>

View File

@ -24,7 +24,7 @@
<string name="about_project_site">Sito web del progetto:\n
https://husky.fwgs.ru</string>
https://huskyapp.dev</string>
@ -44,12 +44,12 @@
<string name="dialog_whats_an_instance">L\'indirizzo o il dominio di qualsiasi istanza può essere inserito qui, come shitposter.club, blob.cat, expired.mentality.rip, e <a href="https://fediverse.network/pleroma?count=peers">altro!</a>
\n
\nSe non hai ancora un account, puoi inserire il nome di un\'istanza alla quale vuoi iscriverti e creare un account.
\n
\n
\nSe non hai ancora un account, puoi inserire il nome di un\'istanza alla quale vuoi iscriverti e creare un account.
\n
\nUn\'istanza è il luogo dove l\'account è custodito, ma puoi facilmente comunicare e seguire gente su altre istanze come se fossero sullo stesso sito.
\n
\nPiù info possono essere trovate su <a href="https://joinmastodon.org">joinmastodon.org</a>.</string>
\n
\nPiù info possono essere trovate su <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -21,7 +21,7 @@
<string name="about_project_site"> プロジェクトのWebサイト英語\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -39,15 +39,18 @@
<string name="add_account_description">新しいPleromaアカウントを追加</string>
<string name="warning_scheduling_interval">Pleromaにおける予約までの最小間隔は5分です。</string>
<string name="dialog_whats_an_instance">shitposter.club, mstdn.jp, pawoo.netや<!-- --><a href="https://fediverse.network/pleroma?count=peers">その他</a><!-- -->のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
\nまだアカウントをお持ちでない場合は、参加したいインスタンスの名前を入力することで<!-- -->そのインスタンスにアカウントを作成できます。
\n
\nインスタンスはあなたのアカウントが提供される単独の場所ですが、<!-- -->他のインスタンスのユーザーとあたかも同じ場所にいるように簡単にコミュニケーションをとったりフォローしたりできます。
\n
<string name="dialog_whats_an_instance">shitposter.club, blob.cat, expired.mentality.ripや<a href="https://fediverse.network/pleroma?count=peers">その他</a> のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
\nまだアカウントをお持ちでない場合は、参加したいインスタンスの名前を入力することで そのインスタンスにアカウントを作成できます。
\n
\nインスタンスはあなたのアカウントが提供される単独の場所ですが、他のインスタンスのユーザーとあたかも同じ場所にいるように簡単にコミュニケーションをとったりフォローしたりできます。
\n
\nさらに詳しい情報は<a href="https://joinmastodon.org">joinmastodon.org</a>でご覧いただけます。 </string>

View File

@ -13,7 +13,7 @@
<string name="about_project_site">Asmel Web n usenfaṛ:
\n https://husky.fwgs.ru</string>
\n https://huskyapp.dev</string>

View File

@ -19,7 +19,7 @@
<string name="about_project_site">프로젝트 홈페이지:
\n https://husky.fwgs.ru</string>
\n https://huskyapp.dev</string>
@ -40,22 +40,11 @@
<string name="dialog_whats_an_instance">인스턴스의 도메인 주소나 IP주소를 입력하실 수 있습니다. shitposter.club, blob.cat, expired.mentality.rip 등이 있으며, 그 외에도 <a href="https://fediverse.network/pleroma?count=peers">더 많은 인스턴스</a>가 당신을 기다리고 있습니다!
\n
\n
\n만약 계정이 없으시다면, 인스턴스 주소를 입력하신 후에 계정을 만드실 수 있습니다.
\n
\n
\n
\n여러분이 어느 인스턴스에 가입하시더라도, 다른 인스턴스에 있는 유저들과 문제 없이 소통하실 수 있습니다.
\n
\n
\n
\n자세한 사항은 &lt;a href=“https://joinmastodon.org”&gt;joinmastodon.org&lt;/a&gt;을 참조하세요. <a href="https://fediverse.network/pleroma?count=peers">more!</a>
\n
\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there.
\n
\nAn instance is a single place where your account is hosted, but you can easily communicate with and follow folks on other instances as though you were on the same site.
\n
\nMore info can be found at <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
\n자세한 사항은 <a href="https://joinmastodon.org">joinmastodon.org</a>을 참조하세요. </string>
</resources>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">Projectwebsite:\n
https://husky.fwgs.ru</string>
https://huskyapp.dev</string>
@ -50,7 +50,7 @@
\n
\nEen Mastodonserver (Engels: instance) is een computerserver waar jouw account zich bevindt (vergelijk het met een e-mailserver). Je kan eenvoudig mensen van andere servers volgen en met ze communiceren, alsof jullie met elkaar op dezelfde website zitten.
\n
\n Meer informatie kun je vinden op <a href="https://joinmastodon.org">joinmastodon.org</a>.</string>
\n Meer informatie kun je vinden op <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">Hjemmeside:
\n https://husky.fwgs.ru</string>
\n https://huskyapp.dev</string>
@ -44,13 +44,13 @@
<string name="dialog_whats_an_instance">Adressen eller domenet til en instans kan skrives inn her, for eksempel shitposter.club, blob.cat, expired.mentality.rip, og <a href="https://fediverse.network/pleroma?count=peers">flere!</a>!
\n
\nHvis du ikke har en konto, kan du skrive inn navnet på instansen du ønsker å opprette en konto på her.
\n
\nEn instans er en sted hvor kontoen din er registrert, men du kan enkelt kommunisere med og følge brukere på andre instanser som om dere er på den samme instansen.
\n
\nMer informasjon finner du på <a href="https://joinmastodon.org">joinmastodon.org</a>.</string>
<string name="dialog_whats_an_instance"><a href="https://fediverse.network/pleroma?count=peers">more!</a>
\n
\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there.
\n
\nAn instance is a single place where your account is hosted, but you can easily communicate with and follow folks on other instances as though you were on the same site.
\n
\nMore info can be found at <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -24,7 +24,7 @@
<string name="about_project_site"> Site web del projècte :\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -22,7 +22,7 @@
<string name="about_project_site"> Strona projektu:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -41,20 +41,18 @@
<string name="add_account_description">Dodaj nowe Konto Pleroma</string>
<string name="warning_scheduling_interval">Pleroma umożliwia wysłanie minimalnie 5 minut od zaplanowania</string>
<string name="warning_scheduling_interval">Pleroma umożliwia wysłanie minimalnie 5 minut od zaplanowania.</string>
<string name="dialog_whats_an_instance">Tutaj można wprowadzić domenę lub adres instancji,
np. shitposter.club, blob.cat, expired.mentality.rip, i
<a href="https://fediverse.network/pleroma?count=peers">wiele więcej!</a>
\n\nJeżeli nie posiadasz jeszcze konta, wprowadź tu nazwę instancji, na której chcesz się zarejestrować.
Instancja jest miejscem, na którym znajduje się twoje konto,
lecz komunikując się z innymi serwerami, działa tak,
jakby były jednym portalem.
Więcej informacji można znaleźć na <a href="https://joinmastodon.org">joinmastodon.org</a>.
</string>
<string name="dialog_whats_an_instance">Tutaj można wprowadzić domenę lub adres instancji, np. shitposter.club, blob.cat, expired.mentality.rip, i <a href="https://fediverse.network/pleroma?count=peers">wiele więcej!</a>
\n
\nJeżeli nie posiadasz jeszcze konta, wprowadź tu nazwę instancji, na której chcesz się zarejestrować.
\n
\nInstancja jest miejscem, na którym znajduje się twoje konto, lecz komunikując się z innymi serwerami, działa tak, jakby były jednym portalem.
\n
\nWięcej informacji można znaleźć na <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -11,7 +11,7 @@
<string name="about_tusky_account">Perfil do Husky</string>
<string name="restart_emoji">É necessário reiniciar o Husky para aplicar essas mudanças</string>
<string name="restart_emoji">É necessário reiniciar o aplicativo para aplicar as alterações</string>
<string name="license_description">O Husky contém código e recursos dos seguintes projetos de código aberto:</string>
@ -24,7 +24,7 @@
<string name="about_project_site">Site do projeto:\n
https://husky.fwgs.ru</string>
https://huskyapp.dev</string>

View File

@ -25,7 +25,7 @@
<string name="about_project_site">
Веб-сайт проекта:\n
https://husky.fwgs.ru</string>
https://huskyapp.dev</string>

View File

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chats">Чаты</string>
<string name="action_mark_as_read">Пометить как прочитанное</string>
<string name="action_reply_to">Ответ на</string>
<!--<string name="action_mute_conversation">Заглушить разговор</string>
<string name="action_unmute_conversation">Отменить глушение разговора</string>-->
@ -17,8 +19,12 @@
<string name="notification_emoji_format">%s среагировал с %s на ваш пост</string>
<string name="notification_emoji_name">Эмодзи реакции</string>
<string name="notification_emoji_description">Уведомления о новых эмодзи реакциях</string>
<string name="notification_chat_message_format">%s отправил вам сообщение</string>
<string name="notification_chat_message_name">Сообщения</string>
<string name="notification_chat_message_description">Уведомления о новых сообщениях</string>
<string name="pref_title_default_formatting">Синтаксис форматирования по умолчанию(если поддерживается)</string>
<string name="pref_title_notification_filter_emoji">на мои посты отреагировали</string>
<string name="pref_title_notification_filter_chat_messages">получено новое сообщение</string>
<string name="pref_title_hide_muted_users">Скрывать заглушенных пользователей</string>
<string name="error_sticker_fetch">Произошла ошибка при загрузке стикера</string>
<string name="action_send_public">ОТПРАВИТЬ!</string>
@ -39,4 +45,5 @@
<string name="action_access_scheduled_toot">Записи по расписанию</string>
<string name="unreblog_private">Удалить повторение</string>
<string name="compose_shortcut_long_label">Сделать запись</string>
</resources>
<string name="link">Ссылка</string>
</resources>

View File

@ -0,0 +1,56 @@
<resources>
<string name="about_tusky_account">टस्कीवर्यस्य व्यक्तिगतविवरणम्</string>
<string name="about_tusky_license">टस्कीत्यनावृतस्रोतो निःशुल्कतन्त्रांशः। GNU General Public License Version 3 इत्यनेनाऽनुज्ञापितः। अत्राऽनुज्ञापत्रं द्रष्टुं शक्यते:-https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">टस्कीत्यनेनाऽऽश्रितः</string>
<string name="about_tusky_version">टस्की %s</string>
<string name="restart_emoji">पुनश्च टस्कीप्रारम्भोऽपेक्षितो वर्तते परिवर्तनानुसरेण चलितुम्</string>
<string name="license_description">टस्कीत्यस्मिन्निम्नलिखितेभ्योऽनावृतस्रोतःप्रकल्पेभ्यो विध्यादेशाः सन्ति:</string>
<string name="about_project_site">प्रकल्पस्य जालसूत्रम् :
\n https://huskyapp.dev</string>
<string name="about_bug_feature_request_site">अशुद्धीनामावेदनं वैशिष्ट्यनिवेदनञ्च
\n https://git.mentality.rip/FWGS/Husky/issues</string>
<string name="action_login">मास्टुडोनमाध्यमेन सम्प्रविश्यताम्</string>
<string name="add_account_description">नवमास्टोडोनलेखा युज्यताम्</string>
<string name="warning_scheduling_interval">मास्टोडोने पञ्चनिमेषपरिमितो न्यूनतमः कालबद्धसमयः ।</string>
<string name="dialog_whats_an_instance">कस्याऽपि विशिष्टस्थलस्य सङ्केतसूत्रमत्र टङ्कयितुं शक्यते shitposter.club, blob.cat, expired.mentality.rip, तथेैव<a href="https://fediverse.network/pleroma?count=peers">अधिकम्</a>
\n
\nयदि युष्माकं व्यक्तिगतलेखाऽत्र न वर्तते तर्हि तस्य विशिष्टस्थलस्य नाम टङ्कयित्वा तत्र निर्मातुं शक्नुथ ।
\n
\nविशिष्टस्थलमित्युक्ते स्थलमेकं यत्र युष्माकं लेखाः आश्रिताः, किन्तु साफल्येनैवाऽन्यविशिष्टस्थलीयैः सह सम्पर्कयितुं शक्यते ।
\n
\nअधिकमत्र प्राप्यते <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">Spletna stran projekta:
\nhttps://husky.fwgs.ru</string>
\nhttps://huskyapp.dev</string>

View File

@ -22,7 +22,7 @@
<string name="about_project_site"> Tuskys webbsida:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -19,7 +19,7 @@
<string name="about_project_site"> திட்டத்தின் வலைத்தளம்:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -22,7 +22,7 @@
<string name="about_project_site">เว็บไซต์โปรเจกต์:
\nhttps://husky.fwgs.ru</string>
\nhttps://huskyapp.dev</string>

View File

@ -6,10 +6,10 @@
<string name="about_tusky_license">Husky özgür ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görüntüleyebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_tusky_account">Husky\'in Profili</string>
<string name="about_tusky_account">Husky\'nin Profili</string>
<string name="restart_emoji">Değişikliklerin uygulanabilmesi için uygulama yeniden başlatılmalı</string>
<string name="restart_emoji">Bu değişiklikleri uygulamak için Husky\'yi yeniden başlatmanız gerekecek</string>
<string name="license_description">Husky aşağıdakıık kaynaklı projelerden kod ve materyal içeriyor:</string>
@ -21,13 +21,13 @@
<string name="about_project_site">Projenin internet sitesi:
\n https://husky.fwgs.ru</string>
<string name="about_project_site">Projenin internet sitesi:
\n https://huskyapp.dev</string>
<string name="about_bug_feature_request_site">Hata raporları &amp; özellik istekleri:
<string name="about_bug_feature_request_site">&amp; özellik istekleri hata raporları:
\n https://git.mentality.rip/FWGS/Husky/issues</string>
@ -38,15 +38,18 @@
<string name="add_account_description">Yeni Pleroma hesabı ekle</string>
<string name="warning_scheduling_interval">Pleroma\'un minimum 5 dakikalık zamanlama aralığı vardır.</string>
<string name="dialog_whats_an_instance">Burada her hangi bir Mastodon sunucusunun adresi (shitposter.club, blob.cat, expired.mentality.rip, ve <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md">daha fazlasını</a>) girebilirsin!
\n
\nHenüz hesabın yok ise katılmak istediğin sunucunun adresini girerek hesap oluşturabilirsin
\n
\nHer bir sunucu kendi hesap kayıtlarını tutar ancak diğer sunucularda bulunan insanlarla aynı sitedeymişçesine iletişime geçip takip edebilirsin.
\n
<string name="dialog_whats_an_instance">Burada her hangi bir Mastodon sunucusunun adresi (shitposter.club, blob.cat, expired.mentality.rip, ve <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md">daha fazla!</a>) girilebiliri.
\n
\nEğer hesabınız henüz yok ise katılmak istediğiniz sunucunun adresini girerek hesap yaratabilirsin.
\n
\nHer bir sunucu hesaplar ağırlayan bir yer olur ancak diğer sunucularda bulunan insanlarla aynı sitede olmuşcasına iletişime geçip takip edebilirsiniz.
\n
\nDaha fazla bilgi için <a href="https://shitposter.club/about">shitposter.club</a>. </string>

View File

@ -0,0 +1,56 @@
<resources>
<string name="about_tusky_license">Husky là phần mềm mã nguồn mở, được phân phối với giấy phép GNU General Public License Version 3. Bạn có thể tham khảo thêm tại: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">Powered by Husky</string>
<string name="about_tusky_version">Husky %s</string>
<string name="about_tusky_account">Trang cá nhân Husky</string>
<string name="license_description">Husky có sử dụng mã nguồn từ những dự án mã nguồn mở sau:</string>
<string name="restart_emoji">Bạn cần khởi động lại Husky để áp dụng các thiết lập</string>
<string name="about_project_site">Trang chủ
\nhttps://huskyapp.dev</string>
<string name="about_bug_feature_request_site">Báo lỗi và đề xuất tính năng
\nhttps://git.mentality.rip/FWGS/Husky/issues</string>
<string name="action_login">Đăng nhập Pleroma</string>
<string name="warning_scheduling_interval">Pleroma giới hạn tối thiểu 5 phút.</string>
<string name="add_account_description">Thêm tài khoản Pleroma</string>
<string name="dialog_whats_an_instance">Bạn phải nhập một tên miền, ví dụ shitposter.club, blob.cat, expired.mentality.rip, và <a href="https://fediverse.network/pleroma?count=peers">nhiều hơn nữa!</a>
\n
\nNếu chưa có tài khoản, bạn phải tạo tài khoản trước ở đó.
\n
\nMáy chủ, nói cách khác là một cộng đồng nơi mà tài khoản của bạn lưu trữ trên đó, nhưng bạn vẫn có thể giao tiếp và theo dõi mọi người trên các máy chủ khác một cách dễ dàng.
\n
\nTham khảo thêm tại <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
</resources>

View File

@ -23,7 +23,7 @@
<string name="about_project_site">
项目地址:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -43,15 +43,18 @@
<string name="add_account_description">添加新的 Pleroma 帐号</string>
<string name="warning_scheduling_interval">Pleroma的最小调度间隔为5分钟。</string>
<string name="warning_scheduling_interval">Pleroma的最小预订时间为5分钟。</string>
<string name="dialog_whats_an_instance">请输入你帐号所在的 Mastodon 站点的域名,比如 pawoo.netacg.mnwxw.moe<a href="https://fediverse.network/pleroma?count=peers">等等</a>
\n\n还没有 Mastodon 帐号?你也可以输入想注册的 Mastodon 站点的域名,然后在该服务器创建新的帐号并授权 Tusky 登入。
\n\n在 Mastodon 里,跨站互动和站内互动一样简单。可以前往 <a href="https://joinmastodon.org">https://joinmastodon.org</a> 了解更多信息。
</string>
<string name="dialog_whats_an_instance">请输入你帐号所在的 Mastodon 站点的域名,比如 shitposter.clubblob.catexpired.mentality.rip<a href="https://fediverse.network/pleroma?count=peers">等等</a>
\n
\n还没有 Mastodon 帐号?你也可以输入想注册的 Mastodon 站点的域名,然后在该服务器创建新的帐号并授权 Tusky 登入。
\n
\n在 Mastodon 里,你的账号信息储存在某一特定实例当中,但 Mastodon 可使跨站互动和站内互动一样简单。
\n
\n可以前往 <a href="https://joinmastodon.org">https://joinmastodon.org</a> 了解更多信息。 </string>
</resources>

View File

@ -17,7 +17,7 @@
<string name="about_project_site">
專案網站:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -39,7 +39,7 @@
<string name="dialog_whats_an_instance">"請輸入你帳號所在的 Mastodon 站點的域名或地址 "</string>
<string name="dialog_whats_an_instance">請輸入你帳號所在的 Mastodon 站點的域名或地址</string>
</resources>

View File

@ -17,7 +17,7 @@
<string name="about_project_site">
專案網站:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -39,7 +39,7 @@
<string name="dialog_whats_an_instance">"請輸入你帳號所在的 Mastodon 站點的域名或地址 "</string>
<string name="dialog_whats_an_instance">請輸入你帳號所在的 Mastodon 站點的域名或地址</string>
</resources>

View File

@ -20,7 +20,7 @@
<string name="about_project_site">
项目地址:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -20,7 +20,7 @@
<string name="about_project_site">
專案網站:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>
@ -42,7 +42,7 @@
<string name="dialog_whats_an_instance">"請輸入你帳號所在的 Mastodon 站點的域名或地址 "</string>
<string name="dialog_whats_an_instance">請輸入你帳號所在的 Mastodon 站點的域名或地址</string>
</resources>

View File

@ -2,7 +2,7 @@
<!-- HUSKY SPECIFIC STRINGS -->
<!-- REPLACEMENT FOR TUSKY STRINGS -->
<string name="tusky_website" translatable="false">https://husky.fwgs.ru</string>
<string name="tusky_website" translatable="false">https://huskyapp.dev</string>
</resources>

View File

@ -10,4 +10,10 @@
<item>@string/action_bbcode</item>
<item>@string/action_html</item>
</string-array>
<string name="description_muted_status" translatable="false">
<!-- Display name, relative date, username -->
%1$s; %2$s; %3$s
</string>
</resources>

View File

@ -25,7 +25,7 @@
<string name="about_project_site">
Project website:\n
https://husky.fwgs.ru
https://huskyapp.dev
</string>

View File

@ -1,5 +1,11 @@
<resources>
<!-- HUSKY SPECIFIC STRINGS -->
<string name="chats">Chats</string>
<string name="chat_our_last_message"><b>You</b></string>
<string name="error_chat_recipient_unavailable">Recipient does not support Chats</string>
<string name="action_mark_as_read">Mark as read</string>
<string name="action_reply_to">Reply to</string>
<string name="action_emoji_react">React</string>
<string name="action_emoji_unreact">Remove reaction</string>
@ -7,7 +13,10 @@
<string name="action_enable_formatting_syntax">Enable %s</string>
<string name="action_disable_formatting_syntax">Disable %s</string>
<string name="action_sticker">Stickers</string>
<string name="action_open_in_external_app">Open in external app</string>
<string name="action_chat">Open chat</string>
<string name="action_expand_menu">Expand menu</string>
<string name="title_emoji_reacted_by">%s reacted by</string>
<string name="hint_appname">Application name</string>
@ -22,12 +31,32 @@
<string name="notification_emoji_format">%s reacted with %s to your post</string>
<string name="notification_emoji_name">Emoji Reactions</string>
<string name="notification_emoji_description">Notifications about new emoji reactions</string>
<string name="notification_chat_message_format">%s sent you a message</string>
<string name="notification_chat_message_name">Chat Messages</string>
<string name="notification_chat_message_description">Notifications about new chat messages</string>
<string name="pref_title_other">Other</string>
<string name="pref_title_privacy">Privacy</string>
<string name="pref_title_anonymize_upload_filenames">Anonymize uploaded file names</string>
<string name="pref_title_live_notifications">Live notifications</string>
<string name="pref_summary_live_notifications">May slightly increase power consumption</string>
<string name="pref_title_default_formatting">Default formatting syntax(if supported by instance)</string>
<string name="pref_title_notification_filter_emoji">my posts are reacted with emojis</string>
<string name="pref_title_notification_filter_chat_messages">received a chat message</string>
<string name="pref_title_hide_muted_users">Hide muted users</string>
<string name="pref_title_enable_big_emojis">Enable bigger custom emojis</string>
<string name="pref_title_enable_experimental_stickers">Enable experimental Pleroma-FE stickers(if available)</string>
<string name="attachment_type_image">Image</string>
<string name="attachment_type_video">Video</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_unknown">Attachment</string>
<string name="link">Link</string> <!-- Web Link -->
<string name="streaming_notification_name">Live notifications</string>
<string name="streaming_notification_description">Running live notifications for: </string>
<!-- REPLACEMENT FOR TUSKY STRINGS -->
<string name="action_toggle_visibility">Post visibility</string>
@ -80,11 +109,12 @@
<string name="status_share_content">Share content of post</string>
<string name="status_share_link">Share link to post</string>
<string name="status_boosted_format">%s repeated</string>
<string name="status_replied_to_format">Reply to %s</string>
<string name="title_scheduled_toot">Scheduled posts</string>
<string name="title_reblogged_by">Repeated by</string>
<string name="title_view_thread">Post</string>
<!--
<string name="about_tusky_version">Husky %s</string>
<string name="about_powered_by_tusky">Powered by Husky</string>

View File

@ -106,6 +106,9 @@
android:name=".components.compose.ComposeActivity"
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize"/>
<activity
android:name=".components.chat.ChatActivity"
android:windowSoftInputMode="stateVisible|adjustResize"/>
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
@ -117,7 +120,7 @@
android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" />
<activity android:name=".PreferencesActivity" />
<activity android:name=".components.preference.PreferencesActivity" />
<activity android:name=".StatusListActivity" />
<activity android:name=".AccountListActivity" />
<activity android:name=".AboutActivity" />
@ -165,6 +168,8 @@
<service android:name=".service.SendTootService" />
<service android:name=".service.StreamingService" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="24mm"
height="24mm"
viewBox="0 0 24 24"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="ic_bbcode.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.959798"
inkscape:cx="32.222537"
inkscape:cy="16.813518"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1876"
inkscape:window-height="1051"
inkscape:window-x="44"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-273)">
<g
aria-label="[ / ]"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:14.11111069px;line-height:578.50018311;font-family:Roboto;-inkscape-font-specification:Roboto;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
id="text821">
<path
d="m 5.4784346,278.1925 v 1.04731 H 4.1624082 v 11.5204 h 1.3160264 v 1.04731 H 2.8877229 V 278.1925 Z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path823" />
<path
d="M 14.532145,279.62566 10.3498,290.51905 H 9.2542592 l 4.1892358,-10.89339 z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path825" />
<path
d="m 18.507785,279.23981 v -1.04731 h 2.604492 v 13.61502 h -2.604492 v -1.04731 h 1.322917 v -11.5204 z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path827" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

74
app/src/main/ic_html.svg Normal file
View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="24mm"
height="24mm"
viewBox="0 0 24 24"
version="1.1"
id="svg8"
sodipodi:docname="ic_html.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.959798"
inkscape:cx="29.06581"
inkscape:cy="16.813518"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1876"
inkscape:window-height="1051"
inkscape:window-x="44"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-273)">
<g
aria-label="&lt;/&gt;"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:14.11111069px;line-height:578.50018311;font-family:Roboto;-inkscape-font-specification:Roboto;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
id="text821">
<path
d="m 7.9864639,288.23494 -5.636176,-2.61138 v -0.99908 l 5.636176,-2.60449 v 1.35048 l -4.299479,1.77078 4.299479,1.74321 z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path888" />
<path
d="m 14.428792,279.5533 -4.182346,10.89339 h -1.09554 l 4.189236,-10.89339 z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path890" />
<path
d="m 15.765489,282.00621 5.884223,2.60449 v 1.00597 l -5.884223,2.61138 v -1.31603 l 4.561306,-1.81212 -4.561306,-1.77766 z"
style="font-size:14.11111069px;line-height:578.50018311;stroke-width:0.26458332"
id="path892" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="24mm"
height="24mm"
viewBox="0 0 24 24"
version="1.1"
id="svg8"
sodipodi:docname="ic_sticker.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="4"
inkscape:cx="5.4548189"
inkscape:cy="71.290414"
inkscape:document-units="mm"
inkscape:current-layer="svg8"
showgrid="false"
inkscape:window-width="1876"
inkscape:window-height="1051"
inkscape:window-x="44"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:object-paths="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-global="true"
inkscape:snap-smooth-nodes="false"
inkscape:snap-midpoints="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<path
sodipodi:type="inkscape:offset"
inkscape:radius="0.61723572"
inkscape:original="M 17.623047 3.0683594 C 17.509421 3.0655494 17.393876 3.0680084 17.279297 3.078125 L 6.4902344 3.078125 C 4.5726672 3.0249708 2.9115899 4.8344018 3.0839844 6.7363281 C 3.0869044 10.385764 3.0795738 14.036202 3.0898438 17.685547 C 3.1383328 19.540878 4.9086202 21.084854 6.75 20.921875 L 11.855469 20.921875 L 12.105469 20.921875 L 12.355469 20.669922 L 20.644531 12.349609 L 20.927734 12.064453 L 20.927734 11.849609 C 20.927794 9.9997499 20.927719 8.1505896 20.923828 6.3007812 C 20.878728 4.5685513 19.327443 3.1105255 17.623047 3.0683594 z M 12.095703 4.0761719 C 13.93776 4.0746119 15.779621 4.0764408 17.621094 4.0917969 C 19.0166 4.1998379 20.08292 5.57683 19.927734 6.9511719 L 19.927734 11.849609 L 13.728516 11.849609 C 13.471455 11.849609 13.22575 11.902783 13.001953 11.998047 C 12.778155 12.093317 12.576116 12.230525 12.40625 12.400391 C 12.236384 12.570256 12.099172 12.772297 12.003906 12.996094 C 11.908636 13.219891 11.855469 13.465594 11.855469 13.722656 L 11.855469 19.921875 C 10.175931 19.921532 8.4977933 19.918927 6.8183594 19.908203 C 5.2022402 19.843685 3.9152358 18.271407 4.0839844 16.679688 C 4.0924326 13.222849 4.0677822 9.7667037 4.0976562 6.3105469 C 4.2045509 5.0752806 5.3270093 4.0566644 6.5683594 4.078125 C 8.4101806 4.082465 10.253646 4.0777473 12.095703 4.0761719 z M 14.162109 12.849609 L 15.355469 12.849609 L 18.732422 12.849609 L 12.855469 18.75 L 12.855469 15.349609 L 12.855469 14.15625 C 12.855469 13.977032 12.892568 13.804462 12.958984 13.648438 C 13.025404 13.492411 13.121807 13.352801 13.240234 13.234375 C 13.35866 13.115948 13.498271 13.019543 13.654297 12.953125 C 13.810323 12.886705 13.982892 12.849609 14.162109 12.849609 z "
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:40;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
id="path4812"
d="m 17.638672,2.4511719 c -0.11531,-0.00285 -0.236952,0.00108 -0.359375,0.00977 H 6.5078125 C 4.1925376,2.396759 2.2610006,4.5009931 2.46875,6.7929688 l -0.00195,-0.056641 c 0.00292,3.6482799 -0.00442,7.2998419 0.00586,10.9511719 a 0.61729744,0.61729744 0 0 0 0,0.01367 c 0.058506,2.238617 2.1150294,4.032162 4.3320313,3.835937 L 6.75,21.539062 h 5.105469 0.25 a 0.61729744,0.61729744 0 0 0 0.4375,-0.18164 l 0.25,-0.251953 8.289062,-8.320313 L 21.365234,12.5 a 0.61729744,0.61729744 0 0 0 0.179688,-0.435547 v -0.214844 c 6e-5,-1.8498887 -1.4e-5,-3.7001239 -0.0039,-5.5507809 a 0.61729744,0.61729744 0 0 0 0,-0.013672 C 21.486566,4.1938021 19.685497,2.5018096 17.638672,2.4511719 Z m -5.542969,2.2421875 c 1.829572,-0.00155 3.657616,5.622e-4 5.484375,0.015625 1.014403,0.082688 1.848797,1.1605015 1.734375,2.1738281 a 0.61729744,0.61729744 0 0 0 -0.0039,0.068359 v 4.2812501 h -5.582031 c -0.34218,0 -0.671313,0.07066 -0.96875,0.197266 -0.297129,0.126486 -0.563672,0.309764 -0.789063,0.535156 -0.225391,0.225389 -0.40867,0.491924 -0.535156,0.789062 -0.126626,0.297454 -0.197266,0.626581 -0.197266,0.96875 v 5.580078 C 9.7727653,19.301874 8.3083381,19.300307 6.84375,19.291016 5.6139527,19.24192 4.5676965,17.9663 4.6972656,16.744141 a 0.61729744,0.61729744 0 0 0 0.00391,-0.0625 c 0.00842,-3.446433 -0.015574,-6.8868576 0.013672,-10.322266 0.079886,-0.892733 0.9478119,-1.6795516 1.84375,-1.6640625 a 0.61729744,0.61729744 0 0 0 0.00781,0 c 1.8438457,0.00434 3.6879418,-3.783e-4 5.5292968,-0.00195 z m 2.066406,8.7734376 h 1.19336 1.890625 l -3.773438,3.789062 v -1.90625 -1.193359 c 0,-0.09404 0.02007,-0.184299 0.05469,-0.265625 0.03409,-0.08008 0.08309,-0.155357 0.148437,-0.220703 0.06535,-0.06535 0.140636,-0.114354 0.220703,-0.148438 0.08132,-0.03462 0.171582,-0.05469 0.265625,-0.05469 z"
transform="translate(8.2803066e-4,-0.00103277)" />
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -15,6 +15,8 @@
package com.keylesspalace.tusky
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ArgbEvaluator
import android.content.Context
import android.content.Intent
@ -27,6 +29,7 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.viewModels
import androidx.annotation.ColorInt
import androidx.annotation.Px
@ -34,6 +37,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.emoji.text.EmojiCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Observer
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
@ -48,6 +52,7 @@ import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
import com.keylesspalace.tusky.components.chat.ChatActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.di.ViewModelFactory
@ -61,8 +66,11 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_account.*
import kotlinx.android.synthetic.main.view_account_moved.*
import java.text.NumberFormat
@ -160,7 +168,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountMuteButton.hide()
accountFollowsYouTextView.hide()
// setup the RecyclerView for the account fields
accountFieldList.isNestedScrollingEnabled = false
accountFieldList.layoutManager = LinearLayoutManager(this)
@ -279,6 +286,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFloatingActionButton.show()
}
if (verticalOffset < oldOffset) {
hideFabMenu()
accountFloatingActionButton.hide()
}
}
@ -356,8 +364,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
swipeToRefreshLayout.isRefreshing = isRefreshing == true
})
swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeToRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(this,
android.R.attr.colorBackground))
}
private fun onAccountChanged(account: Account?) {
@ -496,6 +502,57 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
private fun FloatingActionButton.menuAnimate(show: Boolean) {
val height = this.height.toFloat()
if(show) {
visibility = View.VISIBLE
alpha = 0.0f
translationY = height
animate().setDuration(200)
.translationY(0.0f)
.alpha(1.0f)
.setListener(object : AnimatorListenerAdapter() {}) // seems listener is saved, so reset it here
.start()
} else {
animate().setDuration(200)
.translationY(height)
.alpha(0.0f)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
visibility = View.GONE
super.onAnimationEnd(animation)
}
})
.start()
}
}
private fun hideFabMenu() {
openedFabMenu = false
accountFloatingActionButton.animate().setDuration(200)
.rotation(0.0f).start()
accountFloatingActionButtonChat.menuAnimate(openedFabMenu)
accountFloatingActionButtonMention.menuAnimate(openedFabMenu)
}
var openedFabMenu = false
private fun animateFabMenu() {
if(openedFabMenu) {
hideFabMenu()
} else {
openedFabMenu = true
accountFloatingActionButton.animate().setDuration(200)
.rotation(135.0f).start()
accountFloatingActionButtonChat.menuAnimate(openedFabMenu)
accountFloatingActionButtonMention.menuAnimate(openedFabMenu)
}
}
/**
* Update account stat info
*/
@ -506,7 +563,28 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowingTextView.text = numberFormat.format(account.followingCount)
accountStatusesTextView.text = numberFormat.format(account.statusesCount)
accountFloatingActionButton.setOnClickListener { mention() }
accountFloatingActionButtonMention.setOnClickListener { mention() }
if(account.pleroma?.acceptsChatMessages == true) {
accountFloatingActionButtonChat.setOnClickListener {
mastodonApi.createChat(account.id)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe({
val intent = ChatActivity.getIntent(this@AccountActivity, it)
startActivityWithSlideInAnimation(intent)
}, {
Toast.makeText(this@AccountActivity, getString(R.string.error_generic), Toast.LENGTH_SHORT).show()
})
}
} else {
accountFloatingActionButtonChat.backgroundTintList = ColorStateList.valueOf(Color.GRAY)
accountFloatingActionButtonChat.setOnClickListener {
Toast.makeText(this@AccountActivity, getString(R.string.error_chat_recipient_unavailable), Toast.LENGTH_SHORT).show()
}
}
accountFloatingActionButton.setOnClickListener { animateFabMenu() }
accountFollowButton.setOnClickListener {
if (viewModel.isSelf) {
@ -613,6 +691,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
updateFollowButton()
if (blocking || viewModel.isSelf) {
hideFabMenu()
accountFloatingActionButton.hide()
accountMuteButton.hide()
accountSubscribeButton.hide()
@ -626,6 +705,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
} else {
hideFabMenu()
accountFloatingActionButton.hide()
accountFollowButton.hide()
accountMuteButton.hide()
@ -829,6 +909,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} else null
}
override fun onActionButtonHidden() {
hideFabMenu()
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {

View File

@ -23,6 +23,8 @@ import android.widget.Toast
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.Lifecycle
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.components.chat.ChatActivity
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
@ -112,6 +114,10 @@ abstract class BottomSheetActivity : BaseActivity() {
startActivityWithSlideInAnimation(intent)
}
open fun openChat(chat: Chat) {
startActivityWithSlideInAnimation(ChatActivity.getIntent(this, chat))
}
protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) {
when (fallbackBehavior) {
PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url)

View File

@ -30,6 +30,7 @@ import android.widget.ImageView
import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
@ -112,7 +113,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isEnabled = false
it.isVisible = false
}
scrollView.post{

View File

@ -1,291 +0,0 @@
package com.keylesspalace.tusky;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.util.EmojiCompatFont;
import java.util.ArrayList;
/**
* This Preference lets the user select their preferred emoji font
*/
public class EmojiPreference extends Preference {
private static final String TAG = "EmojiPreference";
private EmojiCompatFont selected, original;
static final String FONT_PREFERENCE = "selected_emoji_font";
private static final EmojiCompatFont[] FONTS = EmojiCompatFont.FONTS;
// Please note that this array should be sorted in the same way as their fonts.
private static final int[] viewIds = {
R.id.item_nomoji,
R.id.item_blobmoji,
R.id.item_twemoji,
R.id.item_notoemoji};
private ArrayList<RadioButton> radioButtons = new ArrayList<>();
private boolean updated, currentNeedsUpdate;
public EmojiPreference(Context context) {
super(context);
// Find out which font is currently active
this.selected = EmojiCompatFont.byId(PreferenceManager
.getDefaultSharedPreferences(context)
.getInt(FONT_PREFERENCE, 0));
// We'll use this later to determine if anything has changed
this.original = this.selected;
setSummary(selected.getDisplay(context));
}
public EmojiPreference(Context context, AttributeSet attrs) {
super(context, attrs);
// Find out which font is currently active
this.selected = EmojiCompatFont.byId(PreferenceManager
.getDefaultSharedPreferences(context)
.getInt(FONT_PREFERENCE, 0));
// We'll use this later to determine if anything has changed
this.original = this.selected;
setSummary(selected.getDisplay(context));
}
@Override
protected void onClick() {
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_emojicompat, null);
for (int i = 0; i < viewIds.length; i++) {
setupItem(view.findViewById(viewIds[i]), FONTS[i]);
}
new AlertDialog.Builder(getContext())
.setView(view)
.setPositiveButton(android.R.string.ok, (dialog, which) -> onDialogOk())
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void setupItem(View container, EmojiCompatFont font) {
Context context = container.getContext();
TextView title = container.findViewById(R.id.emojicompat_name);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ImageView thumb = container.findViewById(R.id.emojicompat_thumb);
ImageButton download = container.findViewById(R.id.emojicompat_download);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
RadioButton radio = container.findViewById(R.id.emojicompat_radio);
// Initialize all the views
title.setText(font.getDisplay(context));
caption.setText(font.getCaption(context));
thumb.setImageDrawable(font.getThumb(context));
// There needs to be a list of all the radio buttons in order to uncheck them when one is selected
radioButtons.add(radio);
updateItem(font, container);
// Set actions
download.setOnClickListener((downloadButton) ->
startDownload(font, container));
cancel.setOnClickListener((cancelButton) ->
cancelDownload(font, container));
radio.setOnClickListener((radioButton) ->
select(font, (RadioButton) radioButton));
container.setOnClickListener((containterView) ->
select(font,
containterView.findViewById(R.id.emojicompat_radio
)));
}
private void startDownload(EmojiCompatFont font, View container) {
ImageButton download = container.findViewById(R.id.emojicompat_download);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ProgressBar progressBar = container.findViewById(R.id.emojicompat_progress);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
// Switch to downloading style
download.setVisibility(View.GONE);
caption.setVisibility(View.INVISIBLE);
progressBar.setVisibility(View.VISIBLE);
cancel.setVisibility(View.VISIBLE);
font.downloadFont(getContext(), new EmojiCompatFont.Downloader.EmojiDownloadListener() {
@Override
public void onDownloaded(EmojiCompatFont font) {
finishDownload(font, container);
}
@Override
public void onProgress(float progress) {
// The progress is returned as a float between 0 and 1
progress *= progressBar.getMax();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress((int) progress, true);
} else {
progressBar.setProgress((int) progress);
}
}
@Override
public void onFailed() {
Toast.makeText(getContext(), R.string.download_failed, Toast.LENGTH_SHORT).show();
updateItem(font, container);
}
});
}
private void cancelDownload(EmojiCompatFont font, View container) {
font.cancelDownload();
updateItem(font, container);
}
private void finishDownload(EmojiCompatFont font, View container) {
select(font, container.findViewById(R.id.emojicompat_radio));
updateItem(font, container);
// Set the flag to restart the app (because an update has been downloaded)
if (selected == original && currentNeedsUpdate) {
updated = true;
currentNeedsUpdate = false;
}
}
/**
* Select a font both visually and logically
*
* @param font The font to be selected
* @param radio The radio button associated with it's visual item
*/
private void select(EmojiCompatFont font, RadioButton radio) {
selected = font;
// Uncheck all the other buttons
for (RadioButton other : radioButtons) {
if (other != radio) {
other.setChecked(false);
}
}
radio.setChecked(true);
}
/**
* Called when a "consistent" state is reached, i.e. it's not downloading the font
*
* @param font The font to be displayed
* @param container The ConstraintLayout containing the item
*/
private void updateItem(EmojiCompatFont font, View container) {
// Assignments
ImageButton download = container.findViewById(R.id.emojicompat_download);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ProgressBar progress = container.findViewById(R.id.emojicompat_progress);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
RadioButton radio = container.findViewById(R.id.emojicompat_radio);
// There's no download going on
progress.setVisibility(View.GONE);
cancel.setVisibility(View.GONE);
caption.setVisibility(View.VISIBLE);
if (font.isDownloaded(getContext())) {
// Make it selectable
download.setVisibility(View.GONE);
radio.setVisibility(View.VISIBLE);
container.setClickable(true);
} else {
// Make it downloadable
download.setVisibility(View.VISIBLE);
radio.setVisibility(View.GONE);
container.setClickable(false);
}
// Select it if necessary
if (font == selected) {
radio.setChecked(true);
// Update available
if (!font.isDownloaded(getContext())) {
currentNeedsUpdate = true;
}
} else {
radio.setChecked(false);
}
}
/**
* In order to be able to use this font later on, it needs to be saved first.
*/
private void saveSelectedFont() {
int index = selected.getId();
Log.i(TAG, "saveSelectedFont: Font ID: " + index);
// It's saved using the key FONT_PREFERENCE
PreferenceManager
.getDefaultSharedPreferences(getContext())
.edit()
.putInt(FONT_PREFERENCE, index)
.apply();
setSummary(selected.getDisplay(getContext()));
}
/**
* That's it. The user doesn't want to switch between these amazing radio buttons anymore!
* That means, the selected font can be saved (if the user hit OK)
*/
private void onDialogOk() {
saveSelectedFont();
if (selected != original || updated) {
new AlertDialog.Builder(getContext())
.setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart, ((dialog, which) -> {
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
Intent launchIntent = new Intent(getContext(), SplashActivity.class);
PendingIntent mPendingIntent = PendingIntent.getActivity(
getContext(),
// This is the codepoint of the party face emoji :D
0x1f973,
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT);
AlarmManager mgr =
(AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
if (mgr != null) {
mgr.set(
AlarmManager.RTC,
System.currentTimeMillis() + 100,
mPendingIntent);
}
System.exit(0);
})).show();
}
}
}

View File

@ -19,7 +19,6 @@ import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.net.Uri
@ -29,7 +28,6 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
@ -40,6 +38,10 @@ import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
@ -50,15 +52,19 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.service.StreamingService
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -74,6 +80,10 @@ import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity_main.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector {
@ -90,11 +100,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
lateinit var conversationRepository: ConversationsRepository
private lateinit var header: AccountHeaderView
private lateinit var drawerToggle: ActionBarDrawerToggle
private var notificationTabPosition = 0
private var onTabSelectedListener: OnTabSelectedListener? = null
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val emojiInitCallback = object : InitCallback() {
override fun onInitialized() {
if (!isDestroyed) {
@ -164,6 +175,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
startActivity(composeIntent)
}
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
mainToolbar.visible(!hideTopToolbar)
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
mainToolbar.navigationIcon = FixedSizeDrawable(getDrawable(R.drawable.avatar_default), navIconSize, navIconSize)
mainToolbar.menu.add(R.string.action_search).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
@ -176,7 +193,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
}
setupDrawer(savedInstanceState)
setupDrawer(savedInstanceState, addSearchButton = hideTopToolbar)
/* Fetch user info while we're doing other things. This has to be done after setting up the
* drawer, though, because its callback touches the header in the drawer. */
@ -184,12 +201,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setupTabs(showNotificationTab)
// Setup push notifications
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.enablePullNotifications(this)
} else {
NotificationHelper.disablePullNotifications(this)
}
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
@ -197,6 +208,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false)
is PreferenceChangedEvent -> {
when(event.preferenceKey) {
PrefKeys.LIVE_NOTIFICATIONS -> {
initPullNotifications()
}
}
}
}
}
@ -204,6 +222,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Husky"))
}
private fun initPullNotifications() {
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
if(accountManager.areNotificationsStreamingEnabled()) {
StreamingService.startStreaming(this)
NotificationHelper.disablePullNotifications(this)
} else {
StreamingService.stopStreaming(this)
NotificationHelper.enablePullNotifications(this)
}
} else {
StreamingService.stopStreaming(this)
NotificationHelper.disablePullNotifications(this)
}
}
override fun onResume() {
super.onResume()
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
@ -256,8 +289,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
public override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
if (intent != null) {
val statusUrl = intent.getStringExtra(STATUS_URL)
if (statusUrl != null) {
@ -281,9 +312,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
finish()
}
private fun setupDrawer(savedInstanceState: Bundle?) {
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
drawerToggle = ActionBarDrawerToggle(this, mainDrawerLayout, mainToolbar, com.mikepenz.materialdrawer.R.string.material_drawer_open, com.mikepenz.materialdrawer.R.string.material_drawer_close)
mainToolbar.setNavigationOnClickListener {
mainDrawerLayout.open()
}
header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
@ -302,8 +335,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.accountHeaderBackground.setColorFilter(ContextCompat.getColor(this, R.color.headerBackgroundFilter))
header.accountHeaderBackground.setBackgroundColor(ThemeUtils.getColor(this, R.attr.colorBackgroundAccent))
val animateAvatars = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean("animateGifAvatars", false)
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
@ -415,6 +447,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
onClick = ::logout
}
)
if (addSearchButton) {
mainDrawer.addItemsAtPosition(4,
primaryDrawerItem {
nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = {
startActivityWithSlideInAnimation(SearchActivity.getIntent(context))
}
})
}
setSavedInstance(savedInstanceState)
}
@ -430,26 +474,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
EmojiCompat.get().registerInitCallback(emojiInitCallback)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
drawerToggle.onConfigurationChanged(newConfig)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (drawerToggle.onOptionsItemSelected(item)) {
return true
}
return super.onOptionsItemSelected(item)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(mainDrawer.saveInstanceState(outState))
}
private fun setupTabs(selectNotificationTab: Boolean) {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
val activeTabLayout = if(preferences.getString("mainNavPosition", "top") == "bottom") {
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize)
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
(composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
@ -568,9 +599,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
conversationRepository.deleteCacheForAccount(activeAccount.id)
removeShortcut(this, activeAccount)
val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.disablePullNotifications(this)
}
initPullNotifications()
val intent = if (newAccount == null) {
LoginActivity.getIntent(this, false)
} else {
@ -604,9 +633,30 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.load(me.header)
.into(header.accountHeaderBackground)
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
Glide.with(this)
.asDrawable()
.override(navIconSize)
.load(me.avatar)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.into(object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
mainToolbar.navigationIcon = resource
}
override fun onLoadCleared(placeholder: Drawable?) {
mainToolbar.navigationIcon = placeholder
}
})
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
initPullNotifications()
// Show follow requests in the menu, if this is a locked account.
if (me.locked && mainDrawer.getDrawerItem(DRAWER_ITEM_FOLLOW_REQUESTS) == null) {
val followRequestsItem = primaryDrawerItem {

View File

@ -179,7 +179,8 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
/*scheduledAt*/null,
/*sensitive*/null,
/*poll*/null,
item.getFormattingSyntax()
item.getFormattingSyntax(),
/* modifiedInitialState */ true
);
Intent intent = ComposeActivity.startIntent(this, composeOptions);
startActivity(intent);

View File

@ -20,6 +20,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.fragment.ChatsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
@ -32,6 +33,7 @@ const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val HASHTAG = "Hashtag"
const val LIST = "List"
const val CHATS = "Chats"
data class TabData(val id: String,
@StringRes val text: Int,
@ -89,6 +91,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
arguments,
{ arguments.getOrNull(1).orEmpty() }
)
CHATS -> TabData(
CHATS,
R.string.chats,
R.drawable.ic_forum_24px,
{ ChatsFragment() }
)
else -> throw IllegalArgumentException("unknown tab type")
}
}
@ -98,6 +106,7 @@ fun defaultTabs(): List<TabData> {
createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED)
createTabDataFromId(FEDERATED),
createTabDataFromId(CHATS)
)
}

View File

@ -78,7 +78,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
setDisplayShowHomeEnabled(true)
}
currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList()
currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList()
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
currentTabsRecyclerView.adapter = currentTabsAdapter
currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
@ -175,20 +175,20 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
saveTabs()
}
override fun onActionChipClicked(tab: TabData) {
showAddHashtagDialog(tab)
override fun onActionChipClicked(tab: TabData, tabPosition: Int) {
showAddHashtagDialog(tab, tabPosition)
}
override fun onChipClicked(tab: TabData, chipPosition: Int) {
override fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) {
val newArguments = tab.arguments.filterIndexed { i, _ -> i != chipPosition }
val newTab = tab.copy(arguments = newArguments)
val position = currentTabs.indexOf(tab)
currentTabs[position] = newTab
currentTabs[tabPosition] = newTab
saveTabs()
currentTabsAdapter.notifyItemChanged(position)
currentTabsAdapter.notifyItemChanged(tabPosition)
}
private fun showAddHashtagDialog(tab: TabData? = null) {
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
val frameLayout = FrameLayout(this)
val padding = Utils.dpToPx(this, 8)
@ -211,10 +211,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
val position = currentTabs.indexOf(tab)
currentTabs[position] = newTab
currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(position)
currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
@ -286,6 +285,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val chatTab = createTabDataFromId(CHATS)
if (!currentTabs.contains(chatTab)) {
addableTabs.add(chatTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
@ -343,7 +346,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
companion object {
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5
private const val MAX_TAB_COUNT = 9
}
}

View File

@ -18,14 +18,17 @@ package com.keylesspalace.tusky
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.util.Log
import androidx.emoji.text.EmojiCompat
import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.glide.GlideCustomImageLoader
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.uber.autodispose.AutoDisposePlugins
import dagger.android.DispatchingAndroidInjector
@ -55,7 +58,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
// init the custom emoji fonts
val emojiSelection = preferences.getInt(EmojiPreference.FONT_PREFERENCE, 0)
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0)
val emojiConfig = EmojiCompatFont.byId(emojiSelection)
.getConfig(this)
.setReplaceAll(true)
@ -76,6 +79,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
Log.w("RxJava", "undeliverable exception", it)
}
SubsamplingScaleImageView.setPreferredBitmapConfig(Bitmap.Config.ARGB_8888)
BigImageViewer.initialize(GlideCustomImageLoader.with(this))
}

View File

@ -0,0 +1,227 @@
package com.keylesspalace.tusky.adapter
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.text.TextUtils
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.view.MediaPreviewImageView
import com.keylesspalace.tusky.viewdata.ChatMessageViewData
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
class ChatMessagesViewHolder(view: View) : RecyclerView.ViewHolder(view) {
object Key {
const val KEY_CREATED = "created"
}
private val content: TextView = view.findViewById(R.id.content)
private val timestamp: TextView = view.findViewById(R.id.datetime)
private val attachmentView: MediaPreviewImageView = view.findViewById(R.id.attachment)
private val mediaOverlay: ImageView = view.findViewById(R.id.mediaOverlay)
private val attachmentLayout: FrameLayout = view.findViewById(R.id.attachmentLayout)
private val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
private val mediaPreviewUnloaded = ColorDrawable(ThemeUtils.getColor(itemView.context, R.attr.colorBackgroundAccent))
fun setupWithChatMessage(msg: ChatMessageViewData.Concrete, chatActionListener: ChatActionListener, statusDisplayOptions: StatusDisplayOptions, payload: Any?) {
if(payload == null) {
if(msg.content != null)
content.text = msg.content.emojify(msg.emojis, content)
setAttachment(msg.attachment, chatActionListener)
setCreatedAt(msg.createdAt)
} else {
if(payload is List<*>) {
for (item in payload) {
if (ChatsViewHolder.Key.KEY_CREATED == item) {
setCreatedAt(msg.createdAt)
}
}
}
}
}
private fun loadImage(imageView: MediaPreviewImageView,
previewUrl: String?,
meta: Attachment.MetaData?) {
if (TextUtils.isEmpty(previewUrl)) {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(mediaPreviewUnloaded)
.centerInside()
.into(imageView)
} else {
val focus = meta?.focus
if (focus != null) { // If there is a focal point for this attachment:
imageView.setFocalPoint(focus)
Glide.with(imageView)
.load(previewUrl)
.placeholder(mediaPreviewUnloaded)
.centerInside()
.addListener(imageView)
.into(imageView)
} else {
imageView.removeFocalPoint()
Glide.with(imageView)
.load(previewUrl)
.placeholder(mediaPreviewUnloaded)
.centerInside()
.into(imageView)
}
}
}
private fun formatDuration(durationInSeconds: Double): String? {
val seconds = durationInSeconds.roundToInt().toInt() % 60
val minutes = durationInSeconds.toInt() % 3600 / 60
val hours = durationInSeconds.toInt() / 3600
return String.format("%d:%02d:%02d", hours, minutes, seconds)
}
private fun getAttachmentDescription(context: Context, attachment: Attachment): CharSequence {
var duration = ""
if (attachment.meta?.duration != null && attachment.meta.duration > 0) {
duration = formatDuration(attachment.meta.duration.toDouble()) + " "
}
return if (TextUtils.isEmpty(attachment.description)) {
duration + context.getString(R.string.description_status_media_no_description_placeholder)
} else {
duration + attachment.description
}
}
private fun setAttachmentClickListener(view: View, listener: ChatActionListener, attachment: Attachment, animateTransition: Boolean) {
view.setOnClickListener { v: View? ->
val position = adapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onViewMedia(position, if (animateTransition) v else null)
}
}
view.setOnLongClickListener { v: View? ->
val description = getAttachmentDescription(view.context, attachment)
Toast.makeText(view.context, description, Toast.LENGTH_LONG).show()
true
}
}
private fun setAttachment(attachment: Attachment?, listener: ChatActionListener) {
if(attachment == null) {
attachmentLayout.visibility = View.GONE
} else {
attachmentLayout.visibility = View.VISIBLE
val previewUrl: String = attachment.previewUrl
val description: String? = attachment.description
if(description != null && TextUtils.isEmpty(description) ) {
attachmentView.contentDescription = description
} else {
attachmentView.contentDescription = attachmentView.context
.getString(R.string.action_view_media)
}
loadImage(attachmentView, previewUrl, attachment.meta)
when(attachment.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> {
mediaOverlay.visibility = View.VISIBLE
}
else -> {
mediaOverlay.visibility = View.GONE
}
}
setAttachmentClickListener(attachmentView, listener, attachment, true)
}
}
private fun setCreatedAt(createdAt: Date) {
timestamp.text = sdf.format(createdAt)
}
}
class ChatMessagesAdapter(private val dataSource : TimelineAdapter.AdapterDataSource<ChatMessageViewData>,
private val chatActionListener: ChatActionListener,
private val statusDisplayOptions: StatusDisplayOptions,
private val localUserId: String)
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val VIEW_TYPE_OUR_MESSAGE = 0
private val VIEW_TYPE_THEIR_MESSAGE = 1
private val VIEW_TYPE_PLACEHOLDER = 2
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
when(viewType) {
VIEW_TYPE_OUR_MESSAGE -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_our_message, parent, false)
return ChatMessagesViewHolder(view)
}
VIEW_TYPE_THEIR_MESSAGE -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_their_message, parent, false)
return ChatMessagesViewHolder(view)
}
else -> {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status_placeholder, parent, false)
return PlaceholderViewHolder(view)
}
}
}
override fun getItemCount(): Int {
return dataSource.itemCount
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(holder, position, null)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payload: MutableList<Any>) {
bindViewHolder(holder, position, payload)
}
private fun bindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>?) {
val chat: ChatMessageViewData = dataSource.getItemAt(position)
if(holder is PlaceholderViewHolder) {
holder.setup(chatActionListener, (chat as ChatMessageViewData.Placeholder).isLoading)
} else if(holder is ChatMessagesViewHolder) {
holder.setupWithChatMessage(chat as ChatMessageViewData.Concrete, chatActionListener, statusDisplayOptions,
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null)
}
}
override fun getItemViewType(position: Int): Int {
if(dataSource.getItemAt(position) is ChatMessageViewData.Concrete) {
val msg = dataSource.getItemAt(position) as ChatMessageViewData.Concrete
if(msg.accountId == localUserId) {
return VIEW_TYPE_OUR_MESSAGE
}
return VIEW_TYPE_THEIR_MESSAGE
}
return VIEW_TYPE_PLACEHOLDER
}
override fun getItemId(position: Int): Long {
return dataSource.getItemAt(position).getViewDataId().toLong()
}
}

View File

@ -0,0 +1,209 @@
package com.keylesspalace.tusky.adapter
import android.graphics.Typeface
import android.opengl.Visibility
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.text.toSpanned
import androidx.recyclerview.widget.RecyclerView
import at.connyduck.sparkbutton.helpers.Utils
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.TimestampUtils
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.viewdata.ChatViewData
import java.text.SimpleDateFormat
import java.util.*
class ChatsViewHolder(view: View) : RecyclerView.ViewHolder(view) {
object Key {
const val KEY_CREATED = "created"
}
private val avatar: ImageView = view.findViewById(R.id.status_avatar)
private val avatarInset: ImageView = view.findViewById(R.id.status_avatar_inset)
private val displayName: TextView = view.findViewById(R.id.status_display_name)
private val userName: TextView = view.findViewById(R.id.status_username)
private val timestamp: TextView = view.findViewById(R.id.status_timestamp_info)
private val content: TextView = view.findViewById(R.id.status_content)
private val unread: TextView = view.findViewById(R.id.chat_unread)
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
fun setupWithChat(chat: ChatViewData.Concrete,
listener: ChatActionListener,
statusDisplayOptions: StatusDisplayOptions,
localUserId: String,
payload: Any?) {
if (payload == null) {
displayName.text = chat.account.displayName?.emojify(chat.account.emojis, displayName, true)
?: ""
userName.text = userName.context.getString(R.string.status_username_format, chat.account.username)
setUpdatedAt(chat.updatedAt, statusDisplayOptions)
setAvatar(chat.account.avatar, chat.account.bot, statusDisplayOptions)
if (chat.unread <= 0) {
unread.visibility = View.GONE
} else if (chat.unread > 99) {
unread.text = ":)"
} else {
unread.text = chat.unread.toString()
}
avatar.setOnClickListener { listener.onViewAccount(chat.account.id) }
val onLongClickListener = View.OnLongClickListener {
listener.onMore(chat.id, it)
true
}
val onClickListener = View.OnClickListener {
val pos = adapterPosition
if (pos != RecyclerView.NO_POSITION)
listener.openChat(pos)
}
content.setOnLongClickListener(onLongClickListener)
itemView.setOnLongClickListener(onLongClickListener)
content.setOnClickListener(onClickListener)
itemView.setOnClickListener(onClickListener)
if(chat.lastMessage != null) {
var text = if (chat.lastMessage.content != null) {
content.setTypeface(null, Typeface.NORMAL)
chat.lastMessage.content.emojify(chat.lastMessage.emojis, content, true)
} else if (chat.lastMessage.attachment != null) {
content.setTypeface(null, Typeface.ITALIC)
content.resources.getString(chat.lastMessage.attachment.describeAttachmentType())
} else if (chat.lastMessage.card != null) {
content.setTypeface(null, Typeface.ITALIC)
content.resources.getString(R.string.link)
} else ""
content.text = if(chat.lastMessage.accountId == localUserId) {
SpannableStringBuilder.valueOf(content.resources.getText(R.string.chat_our_last_message))
.append(": $text")
} else text
} else {
content.text = ""
}
} else {
if(payload is List<*>) {
for (item in payload as List<*>) {
if (Key.KEY_CREATED == item) {
setUpdatedAt(chat.updatedAt, statusDisplayOptions)
}
}
}
}
}
private fun setAvatar(url: String,
isBot: Boolean,
statusDisplayOptions: StatusDisplayOptions) {
avatar.setPaddingRelative(0, 0, 0, 0)
if (statusDisplayOptions.showBotOverlay && isBot) {
avatarInset.visibility = View.VISIBLE
avatarInset.setBackgroundColor(0x50ffffff)
Glide.with(avatarInset)
.load(R.drawable.ic_bot_24dp)
.into(avatarInset)
} else {
avatarInset.visibility = View.GONE
}
val avatarRadius = itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp);
loadAvatar(url, avatar, avatarRadius,
statusDisplayOptions.animateAvatars)
}
private fun getAbsoluteTime(createdAt: Date?): String? {
if (createdAt == null) {
return "??:??:??"
}
return if (DateUtils.isToday(createdAt.time)) {
shortSdf.format(createdAt)
} else {
longSdf.format(createdAt)
}
}
private fun setUpdatedAt(updatedAt: Date, statusDisplayOptions: StatusDisplayOptions) {
if (statusDisplayOptions.useAbsoluteTime) {
timestamp.text = getAbsoluteTime(updatedAt)
} else {
val then = updatedAt.time
val now = System.currentTimeMillis()
val readout = TimestampUtils.getRelativeTimeSpanString(timestamp.context, then, now)
timestamp.text = readout
}
}
}
class ChatsAdapter(private val dataSource: TimelineAdapter.AdapterDataSource<ChatViewData>,
val statusDisplayOptions: StatusDisplayOptions,
private val chatActionListener: ChatActionListener,
val localUserId: String) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val VIEW_TYPE_CHAT = 0
private val VIEW_TYPE_PLACEHOLDER = 1
override fun getItemCount(): Int {
return dataSource.itemCount
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(holder, position, null)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payload: MutableList<Any>) {
bindViewHolder(holder, position, payload)
}
private fun bindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList<Any>?) {
val chat: ChatViewData = dataSource.getItemAt(position)
if(holder is PlaceholderViewHolder) {
holder.setup(chatActionListener, (chat as ChatViewData.Placeholder).isLoading)
} else if(holder is ChatsViewHolder) {
holder.setupWithChat(chat as ChatViewData.Concrete, chatActionListener,
statusDisplayOptions, localUserId,
if (payloads != null && payloads.isNotEmpty()) payloads[0] else null)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
if(viewType == VIEW_TYPE_CHAT ) {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_chat, parent, false)
return ChatsViewHolder(view)
}
// else VIEW_TYPE_PLACEHOLDER
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_status_placeholder, parent, false)
return PlaceholderViewHolder(view)
}
override fun getItemViewType(position: Int): Int {
if(dataSource.getItemAt(position) is ChatViewData.Concrete)
return VIEW_TYPE_CHAT
return VIEW_TYPE_PLACEHOLDER
}
override fun getItemId(position: Int): Long {
return dataSource.getItemAt(position).getViewDataId().toLong()
}
}

View File

@ -21,6 +21,7 @@ import android.widget.Button;
import android.widget.ProgressBar;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.ChatActionListener;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
@ -34,16 +35,26 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
progressBar = itemView.findViewById(R.id.progressBar);
}
public void setup(final StatusActionListener listener, boolean progress) {
private void setup(boolean progress) {
loadMoreButton.setVisibility(progress ? View.GONE : View.VISIBLE);
progressBar.setVisibility(progress ? View.VISIBLE : View.GONE);
loadMoreButton.setEnabled(true);
}
public void setup(final StatusActionListener listener, boolean progress) {
setup(progress);
loadMoreButton.setOnClickListener(v -> {
loadMoreButton.setEnabled(false);
listener.onLoadMore(getAdapterPosition());
});
}
public void setup(final ChatActionListener listener, boolean progress) {
setup(progress);
loadMoreButton.setOnClickListener( v -> {
loadMoreButton.setEnabled(false);
listener.onLoadMore(getAdapterPosition());
});
}
}

View File

@ -34,14 +34,24 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
private var pollOptions: List<PollOptionViewData> = emptyList()
private var voteCount: Int = 0
private var votersCount: Int? = null
private var mode = RESULT
private var emojis: List<Emoji> = emptyList()
private var resultClickListener: View.OnClickListener? = null
fun setup(options: List<PollOptionViewData>, voteCount: Int, emojis: List<Emoji>, mode: Int) {
fun setup(
options: List<PollOptionViewData>,
voteCount: Int,
votersCount: Int?,
emojis: List<Emoji>,
mode: Int,
resultClickListener: View.OnClickListener?) {
this.pollOptions = options
this.voteCount = voteCount
this.votersCount = votersCount
this.emojis = emojis
this.mode = mode
this.resultClickListener = resultClickListener
notifyDataSetChanged()
}
@ -69,7 +79,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
when(mode) {
RESULT -> {
val percent = calculatePercent(option.votesCount, voteCount)
val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, holder.resultTextView.context)
.emojify(emojis, holder.resultTextView)
holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
@ -77,7 +87,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
val level = percent * 100
holder.resultTextView.background.level = level
holder.resultTextView.setOnClickListener(resultClickListener)
}
SINGLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, holder.radioButton)

View File

@ -17,6 +17,7 @@ import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.util.Log;
import android.graphics.Paint;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
@ -69,6 +70,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private TextView displayName;
private TextView username;
private TextView replyInfo;
private ImageButton replyButton;
private SparkButton reblogButton;
private SparkButton favouriteButton;
@ -121,6 +123,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
timestampInfo = itemView.findViewById(R.id.status_timestamp_info);
content = itemView.findViewById(R.id.status_content);
avatar = itemView.findViewById(R.id.status_avatar);
replyInfo = itemView.findViewById(R.id.reply_info);
replyButton = itemView.findViewById(R.id.status_reply);
reblogButton = itemView.findViewById(R.id.status_inset);
favouriteButton = itemView.findViewById(R.id.status_favourite);
@ -379,6 +382,24 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
protected void setReplyInfo(StatusViewData.Concrete status, StatusActionListener listener) {
if (status.getInReplyToId() != null) {
Context context = replyInfo.getContext();
String replyToAccount = status.getInReplyToAccountAcct();
replyInfo.setText(context.getString(R.string.status_replied_to_format, replyToAccount));
if (!status.getParentVisible()) {
replyInfo.setPaintFlags(replyInfo.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG);
replyInfo.setOnClickListener(null);
} else {
replyInfo.setPaintFlags(replyInfo.getPaintFlags() & (~Paint.STRIKE_THRU_TEXT_FLAG));
replyInfo.setOnClickListener(v -> listener.onViewReplyTo(getAdapterPosition()));
}
replyInfo.setVisibility(View.VISIBLE);
} else {
replyInfo.setVisibility(View.GONE);
}
}
private void setReblogged(boolean reblogged) {
reblogButton.setChecked(reblogged);
}
@ -757,6 +778,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setUsername(status.getNickname());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setReplyInfo(status, listener);
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions);
setReblogged(status.isReblogged());
setFavourited(status.isFavourited());
@ -805,7 +827,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setAccessibilityDelegate(null);
} else {
if (payloads instanceof List)
for (Object item : (List) payloads) {
for (Object item : (List<?>) payloads) {
if (Key.KEY_CREATED.equals(item)) {
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
}
@ -924,7 +946,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
List<PollOptionViewData> options = poll.getOptions();
for (int i = 0; i < args.length; i++) {
if (i < options.size()) {
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotesCount());
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount());
args[i] = buildDescription(options.get(i).getTitle(), percent, context);
} else {
args[i] = "";
@ -967,12 +989,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (expired || poll.getVoted()) {
// no voting possible
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, PollAdapter.RESULT);
View.OnClickListener viewThreadListener = v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onViewThread(position);
}
};
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT, viewThreadListener);
pollButton.setVisibility(View.GONE);
} else {
// voting possible
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE);
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, null);
pollButton.setVisibility(View.VISIBLE);
@ -999,8 +1027,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollInfoText(long timestamp, PollViewData poll,
StatusDisplayOptions statusDisplayOptions,
Context context) {
String votes = numberFormat.format(poll.getVotesCount());
String votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), votes);
String votesText;
if(poll.getVotersCount() == null) {
String voters = numberFormat.format(poll.getVotesCount());
votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters);
} else {
String voters = numberFormat.format(poll.getVotersCount());
votesText = context.getResources().getQuantityString(R.plurals.poll_info_people, poll.getVotersCount(), voters);
}
CharSequence pollDurationInfo;
if (poll.getExpired()) {
pollDurationInfo = context.getString(R.string.poll_info_closed);
@ -1010,8 +1045,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.useAbsoluteTime()) {
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt()));
} else {
String pollDuration = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
pollDurationInfo = context.getString(R.string.poll_info_time_relative, pollDuration);
pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
}
}

View File

@ -36,8 +36,8 @@ interface ItemInteractionListener {
fun onTabRemoved(position: Int)
fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
fun onActionChipClicked(tab: TabData)
fun onChipClicked(tab: TabData, chipPosition: Int)
fun onActionChipClicked(tab: TabData, tabPosition: Int)
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
}
class TabAdapter(private var data: List<TabData>,
@ -62,16 +62,17 @@ class TabAdapter(private var data: List<TabData>,
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val context = holder.itemView.context
if (!small && data[position].id == LIST) {
holder.itemView.textView.text = data[position].arguments.getOrNull(1).orEmpty()
val tab = data[position]
if (!small && tab.id == LIST) {
holder.itemView.textView.text = tab.arguments.getOrNull(1).orEmpty()
} else {
holder.itemView.textView.setText(data[position].text)
holder.itemView.textView.setText(tab.text)
}
val iconDrawable = ThemeUtils.getTintedDrawable(context, data[position].icon, android.R.attr.textColorSecondary)
val iconDrawable = ThemeUtils.getTintedDrawable(context, tab.icon, android.R.attr.textColorSecondary)
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null)
if (small) {
holder.itemView.textView.setOnClickListener {
listener.onTabAdded(data[position])
listener.onTabAdded(tab)
}
}
holder.itemView.imageView?.setOnTouchListener { _, event ->
@ -96,7 +97,7 @@ class TabAdapter(private var data: List<TabData>,
if (!small) {
if (data[position].id == HASHTAG) {
if (tab.id == HASHTAG) {
holder.itemView.chipGroup.show()
/*
@ -104,34 +105,33 @@ class TabAdapter(private var data: List<TabData>,
* The other dynamic chips are inserted in front of the actionChip.
* This code tries to reuse already added chips to reduce the number of Views created.
*/
data[position].arguments.forEachIndexed { i, arg ->
tab.arguments.forEachIndexed { i, arg ->
val chip = holder.itemView.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply {
text = arg
holder.itemView.chipGroup.addView(this, holder.itemView.chipGroup.size - 1)
}
chip.text = arg
if(data[position].arguments.size <= 1) {
if(tab.arguments.size <= 1) {
chip.chipIcon = null
chip.setOnClickListener(null)
} else {
val cancelIcon = ThemeUtils.getTintedDrawable(context, R.drawable.ic_cancel_24dp, android.R.attr.textColorPrimary)
chip.chipIcon = cancelIcon
chip.setOnClickListener {
listener.onChipClicked(data[position], i)
listener.onChipClicked(tab, holder.adapterPosition, i)
}
}
}
while(holder.itemView.chipGroup.size - 1 > data[position].arguments.size) {
holder.itemView.chipGroup.removeViewAt(data[position].arguments.size - 1)
while(holder.itemView.chipGroup.size - 1 > tab.arguments.size) {
holder.itemView.chipGroup.removeViewAt(tab.arguments.size)
}
holder.itemView.actionChip.setOnClickListener {
listener.onActionChipClicked(data[position])
listener.onActionChipClicked(tab, holder.adapterPosition)
}
} else {
@ -140,9 +140,7 @@ class TabAdapter(private var data: List<TabData>,
}
}
override fun getItemCount(): Int {
return data.size
}
override fun getItemCount() = data.size
fun setRemoveButtonVisible(enabled: Boolean) {
if (removeButtonEnabled != enabled) {

View File

@ -2,6 +2,7 @@ package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.ChatMessage
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
@ -22,3 +23,5 @@ data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class ChatMessageDeliveredEvent(val chatMsg: ChatMessage) : Dispatchable
data class ChatMessageReceivedEvent(val chatMsg: ChatMessage) : Dispatchable

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
package com.keylesspalace.tusky.components.chat
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.util.*
import javax.inject.Inject
open class ChatViewModel
@Inject constructor(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
fun getSingleMedia() : ComposeActivity.QueuedMedia? {
return if(media.value?.isNotEmpty() == true)
media.value?.get(0)
else null
}
}

View File

@ -0,0 +1,378 @@
/* Copyright 2019 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.common
import android.net.Uri
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import retrofit2.Response
import java.util.*
import javax.inject.Inject
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
open class CommonComposeViewModel(
private val api: MastodonApi,
private val accountManager: AccountManager,
private val mediaUploader: MediaUploader,
private val db: AppDatabase
) : RxAwareViewModel() {
protected val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
protected val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
protected val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
var tryFetchStickers = false
var anonymizeNames = true
var hasNoAttachmentLimits = false
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
chatLimit = instance?.chatLimit ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
val instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
val software = nodeinfo?.software?.name ?: "mastodon"
if(software.equals("pleroma")) {
hasNoAttachmentLimits = true
ComposeInstanceMetadata(
software = "pleroma",
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
)
} else if(software.equals("pixelfed")) {
ComposeInstanceMetadata(
software = "pixelfed",
supportsMarkdown = false,
supportsBBcode = false,
supportsHTML = false,
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
)
} else {
ComposeInstanceMetadata(
software = "mastodon",
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
supportsBBcode = false,
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
imageLimit = STATUS_IMAGE_SIZE_LIMIT
)
}
}
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val media = mutableLiveData<List<QueuedMedia>>(listOf())
val uploadError = MutableLiveData<Throwable>()
protected val mediaToDisposable = mutableMapOf<Long, Disposable>()
init {
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
version = instance.version,
chatLimit = instance.chatLimit
)
}
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext(
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
)
.subscribe ({ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
}, { throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
})
.autoDispose()
api.getNodeinfoLinks().subscribe({
links -> if(links.links.isNotEmpty()) {
api.getNodeinfo(links.links[0].href).subscribe({
ni -> nodeinfo.postValue(ni)
}, {
err -> Log.d(TAG, "Failed to get nodeinfo", err)
}).autoDispose()
}
}, { err ->
Log.d(TAG, "Failed to get nodeinfo links", err)
}).autoDispose()
}
fun pickMedia(uri: Uri, filename: String?): LiveData<Either<Throwable, QueuedMedia>> {
// We are not calling .toLiveData() here because we don't want to stop the process when
// the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (!hasNoAttachmentLimits
&& type != QueuedMedia.Type.IMAGE
&& mediaItems.isNotEmpty()
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, filename ?: "unknown", anonymizeNames)
}
}
.subscribe({ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
}, { error ->
liveData.postValue(Either.Left(error))
})
.autoDispose()
return liveData
}
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String, anonymizeNames: Boolean): QueuedMedia {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
hasNoAttachmentLimits, anonymizeNames)
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem, videoLimit, imageLimit )
.subscribe ({ event ->
val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(id = event.attachment.id, uploadPercent = -1)
}
synchronized(media) {
val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(if (index == -1) {
mediaValue + newMediaItem
} else {
mediaValue.toMutableList().also { it[index] = newMediaItem }
})
}
}, { error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
})
return mediaItem
}
protected fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
hasNoAttachmentLimits, anonymizeNames, -1, id, description)
media.value = media.value!! + mediaItem
}
fun removeMediaFromQueue(item: QueuedMedia) {
mediaToDisposable[item.localId]?.dispose()
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
}
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
val newList = media.value!!.toMutableList()
val index = newList.indexOfFirst { it.localId == localId }
if (index != -1) {
newList[index] = newList[index].copy(description = description)
}
media.value = newList
val completedCaptioningLiveData = MutableLiveData<Boolean>()
media.observeForever(object : Observer<List<QueuedMedia>> {
override fun onChanged(mediaItems: List<QueuedMedia>) {
val updatedItem = mediaItems.find { it.localId == localId }
if (updatedItem == null) {
media.removeObserver(this)
} else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description)
.subscribe({
completedCaptioningLiveData.postValue(true)
}, {
completedCaptioningLiveData.postValue(false)
})
.autoDispose()
media.removeObserver(this)
}
}
})
return completedCaptioningLiveData
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
when (token[0]) {
'@' -> {
return try {
api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
}
'#' -> {
return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) {
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) {
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
}
}
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
}
results.addAll(resultsInside)
return results
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
return emptyList()
}
}
}
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private fun getStickers() {
if(!tryFetchStickers)
return
api.getStickers().subscribe({ stickers ->
if (stickers.isNotEmpty()) {
haveStickers.postValue(true)
val singles = mutableListOf<Single<Response<StickerPack>>>()
for(entry in stickers) {
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
singles += api.getStickerPack(url)
}
Single.zip(singles) {
it.map {
it as Response<StickerPack>
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
it.body()!!
}
}.onErrorReturn {
Log.d(TAG, "Failed to get sticker pack.json", it)
emptyList()
}.subscribe() { pack ->
if(pack.isNotEmpty()) {
val array = pack.toTypedArray()
array.sort()
this.stickers.postValue(array)
}
}.autoDispose()
}
}, {
err -> Log.d(TAG, "Failed to get sticker.json", err)
}).autoDispose()
}
fun setup() {
getStickers() // early as possible
}
private companion object {
const val TAG = "CCVM"
}
}
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500
const val DEFAULT_MAX_OPTION_COUNT = 4
const val DEFAULT_MAX_OPTION_LENGTH = 25
const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
data class ComposeInstanceParams(
val maxChars: Int,
val chatLimit: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
data class ComposeInstanceMetadata(
val software: String,
val supportsMarkdown: Boolean,
val supportsBBcode: Boolean,
val supportsHTML: Boolean,
val videoLimit: Long,
val imageLimit: Long
)

View File

@ -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.components.compose;
package com.keylesspalace.tusky.components.common;
import android.content.ContentResolver;
import android.graphics.Bitmap;

View File

@ -13,11 +13,13 @@
* 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.compose
package com.keylesspalace.tusky.components.common
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
@ -44,10 +46,10 @@ sealed class UploadEvent {
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
}
fun createNewImageFile(context: Context): File {
fun createNewImageFile(context: Context, name: String = "Photo"): File {
// Create an image file name
val randomId = randomAlphanumericString(12)
val imageFileName = "Husky_${randomId}_"
val randomId = randomAlphanumericString(4)
val imageFileName = "${name}_${randomId}"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile(
imageFileName, /* prefix */
@ -132,22 +134,22 @@ class MediaUploaderImpl(
if (mediaSize > videoLimit) {
throw VideoSizeException()
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
PreparedMedia(QueuedMedia.VIDEO, uri, mediaSize)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
PreparedMedia(QueuedMedia.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > videoLimit) { // TODO: CHANGE!!11
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
PreparedMedia(QueuedMedia.AUDIO, uri, mediaSize)
}
else -> {
if (mediaSize > videoLimit) {
throw MediaSizeException()
}
PreparedMedia(QueuedMedia.Type.UNKNOWN, uri, mediaSize)
PreparedMedia(QueuedMedia.UNKNOWN, uri, mediaSize)
// throw MediaTypeException()
}
}
@ -162,7 +164,8 @@ class MediaUploaderImpl(
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
return Observable.create { emitter ->
var (mimeType, fileExtension) = getMimeTypeAndSuffixFromFilenameOrUri(media.uri, media.originalFileName)
val filename = String.format("%s_%s_%s%s",
val filename = if(!media.anonymizeFileName) media.originalFileName else
String.format("%s_%s_%s%s",
context.getString(R.string.app_name),
Date().time.toString(),
randomAlphanumericString(10),
@ -197,7 +200,7 @@ class MediaUploaderImpl(
}
private fun downsize(media: QueuedMedia, imageLimit: Long): QueuedMedia {
val file = createNewImageFile(context)
val file = createNewImageFile(context, media.originalFileName)
DownsizeImageTask.resize(arrayOf(media.uri), imageLimit, context.contentResolver, file)
return media.copy(uri = file.toUri(), mediaSize = file.length())
}
@ -226,3 +229,27 @@ class MediaUploaderImpl(
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
}
}
fun Uri.toFileName(contentResolver: ContentResolver? = null): String {
var result: String = "unknown"
if(scheme.equals("content") && contentResolver != null) {
val cursor = contentResolver.query(this, null, null, null, null)
cursor?.use{
if(it.moveToFirst()) {
result = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
}
if(result.equals("unknown")) {
path?.let {
result = it
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}

View File

@ -23,7 +23,6 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.ContentResolver
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
@ -53,7 +52,6 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
@ -61,7 +59,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
@ -72,6 +69,7 @@ import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.common.*
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
@ -82,6 +80,7 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.EmojiKeyboard
import com.mikepenz.iconics.IconicsDrawable
@ -150,6 +149,7 @@ class ComposeActivity : BaseActivity(),
val activeAccount = accountManager.activeAccount ?: return
viewModel.tryFetchStickers = preferences.getBoolean("stickers", false)
viewModel.anonymizeNames = preferences.getBoolean(PrefKeys.ANONYMIZE_FILENAMES, false)
setupAvatar(preferences, activeAccount)
val mediaAdapter = MediaPreviewAdapter(
this,
@ -210,35 +210,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun uriToFilename(uri: Uri): String {
var result: String = "unknown"
if(uri.scheme.equals("content")) {
val cursor = contentResolver.query(uri, null, null, null, null)
cursor?.let {
try {
if(cursor.moveToFirst()) {
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
finally {
cursor.close()
}
}
}
if(result.equals("unknown")) {
val path = uri.getPath()
path?.let {
result = path
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) {
/* Get incoming images being sent through a share action from another app. Only do this
@ -1129,7 +1100,7 @@ class ComposeActivity : BaseActivity(),
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
withLifecycleContext {
viewModel.pickMedia(uri, filename ?: uriToFilename(uri)).observe { exceptionOrItem ->
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()
@ -1300,6 +1271,7 @@ class ComposeActivity : BaseActivity(),
val mediaSize: Long,
val originalFileName: String,
val noChanges: Boolean = false,
val anonymizeFileName: Boolean = false,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
@ -1345,7 +1317,8 @@ class ComposeActivity : BaseActivity(),
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var formattingSyntax: String? = null
var formattingSyntax: String? = null,
var modifiedInitialState: Boolean? = null
) : Parcelable
companion object {

View File

@ -22,6 +22,10 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.common.CommonComposeViewModel
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.common.UploadEvent
import com.keylesspalace.tusky.components.common.mutableLiveData
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager
@ -36,17 +40,10 @@ import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import io.reactivex.schedulers.Schedulers
import retrofit2.Response
import java.util.*
import javax.inject.Inject
/**
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
class ComposeViewModel
@Inject constructor(
private val api: MastodonApi,
@ -55,7 +52,7 @@ class ComposeViewModel
private val serviceClient: ServiceClient,
private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase
) : RxAwareViewModel() {
) : CommonComposeViewModel(api, accountManager, mediaUploader, db) {
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
@ -65,58 +62,9 @@ class ComposeViewModel
private var inReplyToId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
private val nodeinfo: MutableLiveData<NodeInfo?> = MutableLiveData(null)
private val stickers: MutableLiveData<Array<StickerPack>> = MutableLiveData(emptyArray())
public val haveStickers: MutableLiveData<Boolean> = MutableLiveData(false)
public var tryFetchStickers = false
public var formattingSyntax: String = ""
public var hasNoAttachmentLimits = false
private var modifiedInitialState: Boolean = false
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
ComposeInstanceParams(
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
)
}
val instanceMetadata: LiveData<ComposeInstanceMetadata> = nodeinfo.map { nodeinfo ->
val software = nodeinfo?.software?.name ?: "mastodon"
if(software.equals("pleroma")) {
hasNoAttachmentLimits = true
ComposeInstanceMetadata(
software = "pleroma",
supportsMarkdown = nodeinfo?.metadata?.postFormats?.contains("text/markdown") ?: false,
supportsBBcode = nodeinfo?.metadata?.postFormats?.contains("text/bbcode") ?: false,
supportsHTML = nodeinfo?.metadata?.postFormats?.contains("text/html") ?: false,
videoLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.uploadLimits?.general ?: STATUS_IMAGE_SIZE_LIMIT
)
} else if(software.equals("pixelfed")) {
ComposeInstanceMetadata(
software = "pixelfed",
supportsMarkdown = false,
supportsBBcode = false,
supportsHTML = false,
videoLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_VIDEO_SIZE_LIMIT,
imageLimit = nodeinfo?.metadata?.config?.uploader?.maxPhotoSize?.let { it * 1024 } ?: STATUS_IMAGE_SIZE_LIMIT
)
} else {
ComposeInstanceMetadata(
software = "mastodon",
supportsMarkdown = nodeinfo?.software?.version?.contains("+glitch") ?: false,
supportsBBcode = false,
supportsHTML = nodeinfo?.software?.version?.contains("+glitch") ?: false,
videoLimit = STATUS_VIDEO_SIZE_LIMIT,
imageLimit = STATUS_IMAGE_SIZE_LIMIT
)
}
}
val instanceStickers: LiveData<Array<StickerPack>> = stickers // .map { stickers -> HashMap<String,String>(stickers) }
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
@ -129,125 +77,6 @@ class ComposeViewModel
val setupComplete = mutableLiveData(false)
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
val media = mutableLiveData<List<QueuedMedia>>(listOf())
val uploadError = MutableLiveData<Throwable>()
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
init {
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
emojiList = emojis,
maximumTootCharacters = instance.maxTootChars,
maxPollOptions = instance.pollLimits?.maxOptions,
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
version = instance.version
)
}
.doOnSuccess {
db.instanceDao().insertOrReplace(it)
}
.onErrorResumeNext(
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
)
.subscribe ({ instanceEntity ->
emoji.postValue(instanceEntity.emojiList)
instance.postValue(instanceEntity)
}, { throwable ->
// this can happen on network error when no cached data is available
Log.w(TAG, "error loading instance data", throwable)
})
.autoDispose()
api.getNodeinfoLinks().subscribe({
links -> if(links.links.isNotEmpty()) {
api.getNodeinfo(links.links[0].href).subscribe({
ni -> nodeinfo.postValue(ni)
}, {
err -> Log.d(TAG, "Failed to get nodeinfo", err)
}).autoDispose()
}
}, { err ->
Log.d(TAG, "Failed to get nodeinfo links", err)
}).autoDispose()
}
fun pickMedia(uri: Uri, filename: String?): LiveData<Either<Throwable, QueuedMedia>> {
// We are not calling .toLiveData() here because we don't want to stop the process when
// the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
mediaUploader.prepareMedia(uri, videoLimit, imageLimit, filename)
.map { (type, uri, size) ->
val mediaItems = media.value!!
if (!hasNoAttachmentLimits
&& type != QueuedMedia.Type.IMAGE
&& mediaItems.isNotEmpty()
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException()
} else {
addMediaToQueue(type, uri, size, filename ?: "unknown")
}
}
.subscribe({ queuedMedia ->
liveData.postValue(Either.Right(queuedMedia))
}, { error ->
liveData.postValue(Either.Left(error))
})
.autoDispose()
return liveData
}
private fun addMediaToQueue(type: Int, uri: Uri, mediaSize: Long, filename: String): QueuedMedia {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize, filename,
hasNoAttachmentLimits)
val imageLimit = instanceMetadata.value?.videoLimit ?: STATUS_VIDEO_SIZE_LIMIT
val videoLimit = instanceMetadata.value?.imageLimit ?: STATUS_IMAGE_SIZE_LIMIT
media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem, videoLimit, imageLimit )
.subscribe ({ event ->
val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe
val newMediaItem = when (event) {
is UploadEvent.ProgressEvent ->
item.copy(uploadPercent = event.percentage)
is UploadEvent.FinishedEvent ->
item.copy(id = event.attachment.id, uploadPercent = -1)
}
synchronized(media) {
val mediaValue = media.value!!
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
media.postValue(if (index == -1) {
mediaValue + newMediaItem
} else {
mediaValue.toMutableList().also { it[index] = newMediaItem }
})
}
}, { error ->
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
uploadError.postValue(error)
})
return mediaItem
}
private fun addUploadedMedia(id: String, type: Int, uri: Uri, description: String?) {
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, "unknown",
hasNoAttachmentLimits, -1, id, description)
media.value = media.value!! + mediaItem
}
fun removeMediaFromQueue(item: QueuedMedia) {
mediaToDisposable[item.localId]?.dispose()
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
}
fun didChange(content: String?, contentWarning: String?): Boolean {
@ -260,7 +89,7 @@ class ComposeViewModel
val mediaChanged = !media.value.isNullOrEmpty()
val pollChanged = poll.value != null
return textChanged || contentWarningChanged || mediaChanged || pollChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
}
fun contentWarningChanged(value: Boolean) {
@ -342,85 +171,6 @@ class ComposeViewModel
}
}
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
val newList = media.value!!.toMutableList()
val index = newList.indexOfFirst { it.localId == localId }
if (index != -1) {
newList[index] = newList[index].copy(description = description)
}
media.value = newList
val completedCaptioningLiveData = MutableLiveData<Boolean>()
media.observeForever(object : Observer<List<QueuedMedia>> {
override fun onChanged(mediaItems: List<QueuedMedia>) {
val updatedItem = mediaItems.find { it.localId == localId }
if (updatedItem == null) {
media.removeObserver(this)
} else if (updatedItem.id != null) {
api.updateMedia(updatedItem.id, description)
.subscribe({
completedCaptioningLiveData.postValue(true)
}, {
completedCaptioningLiveData.postValue(false)
})
.autoDispose()
media.removeObserver(this)
}
}
})
return completedCaptioningLiveData
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
when (token[0]) {
'@' -> {
return try {
api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
}
'#' -> {
return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
emptyList()
}
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) {
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) {
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
}
}
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
}
results.addAll(resultsInside)
return results
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
return emptyList()
}
}
}
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
@ -428,45 +178,8 @@ class ComposeViewModel
super.onCleared()
}
private fun getStickers() {
if(!tryFetchStickers)
return
api.getStickers().subscribe({ stickers ->
if (stickers.isNotEmpty()) {
haveStickers.postValue(true)
val singles = mutableListOf<Single<Response<StickerPack>>>()
for(entry in stickers) {
val url = entry.value.removePrefix("/").removeSuffix("/") + "/pack.json";
singles += api.getStickerPack(url)
}
Single.zip(singles) {
it.map {
it as Response<StickerPack>
it.body()!!.internal_url = it.raw().request.url.toString().removeSuffix("pack.json")
it.body()!!
}
}.onErrorReturn {
Log.d(TAG, "Failed to get sticker pack.json", it)
emptyList()
}.subscribe() { pack ->
if(pack.isNotEmpty()) {
val array = pack.toTypedArray()
array.sort()
this.stickers.postValue(array)
}
}.autoDispose()
}
}, {
err -> Log.d(TAG, "Failed to get sticker.json", err)
}).autoDispose()
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
getStickers() // early as possible
super.setup()
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
@ -474,7 +187,7 @@ class ComposeViewModel
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
if (contentWarning != null) {
@ -554,30 +267,4 @@ class ComposeViewModel
private companion object {
const val TAG = "ComposeViewModel"
}
}
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
const val DEFAULT_CHARACTER_LIMIT = 500
private const val DEFAULT_MAX_OPTION_COUNT = 4
private const val DEFAULT_MAX_OPTION_LENGTH = 25
private const val STATUS_VIDEO_SIZE_LIMIT : Long = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT : Long = 8388608 // 8MiB
data class ComposeInstanceParams(
val maxChars: Int,
val pollMaxOptions: Int,
val pollMaxLength: Int,
val supportsScheduled: Boolean
)
data class ComposeInstanceMetadata(
val software: String,
val supportsMarkdown: Boolean,
val supportsBBcode: Boolean,
val supportsHTML: Boolean,
val videoLimit: Long,
val imageLimit: Long
)

View File

@ -133,7 +133,6 @@ class MediaPreviewAdapter(
view.marqueeRepeatLimit = -1
view.setSingleLine()
view.setSelected(true)
view.maxLines = 1
view.textSize = 16.0f
view.setOnClickListener {
onMediaClick(adapterPosition, view)

View File

@ -35,13 +35,14 @@ import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.github.piasy.biv.loader.glide.GlideCustomImageLoader
import com.github.piasy.biv.view.BigImageView
import com.github.piasy.biv.loader.ImageLoader
import com.github.piasy.biv.view.GlideImageViewFactory
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.withLifecycleContext
import java.io.File
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
fun <T> T.makeCaptionDialog(existingDescription: String?,
previewUri: Uri,
@ -53,8 +54,19 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
dialogLayout.orientation = LinearLayout.VERTICAL
val imageView = BigImageView(this)
// imageView.ssiv.maxScale = 6f
imageView.setImageViewFactory(GlideImageViewFactory())
imageView.setImageLoaderCallback(object : ImageLoader.Callback {
override fun onSuccess(image: File?) {
imageView.ssiv?.let { it.maxScale = 6f }
}
override fun onFail(error: Exception?) {}
override fun onStart() {}
override fun onCacheHit(imageType: Int, image: File?) {}
override fun onCacheMiss(imageType: Int, image: File?) {}
override fun onFinish() {}
override fun onProgress(progress: Int) {}
})
imageView.showImage(previewUri)
val displayMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(displayMetrics)
@ -71,7 +83,9 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
dialogLayout.addView(input)
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
input.setLines(2)
input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
input.inputType = (InputType.TYPE_CLASS_TEXT
or InputType.TYPE_TEXT_FLAG_MULTI_LINE
or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
input.setText(existingDescription)
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
@ -97,12 +111,8 @@ fun <T> T.makeCaptionDialog(existingDescription: String?,
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
dialog.show()
// Load the image and manually set it into the ImageView because it doesn't have a fixed
// size. Maybe we should limit the size of CustomTarget
imageView.showImage(previewUri)
}
private fun Activity.showFailedCaptionMessage() {
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
}
}

View File

@ -91,8 +91,8 @@ public final class ProgressImageView extends AppCompatImageView {
super.onDraw(canvas);
float angle = (progress / 100f) * 360 - 90;
float halfWidth = getWidth() / 2;
float halfHeight = getHeight() / 2;
float halfWidth = getWidth() / 2.0f;
float halfHeight = getHeight() / 2.0f;
progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f);
biggerRect.set(progressRect);
int margin = 8;

View File

@ -90,8 +90,8 @@ public final class ProgressTextView extends TextView {
canvas.translate(getScrollX(), 0);
float angle = (progress / 100f) * 360 - 90;
float halfWidth = getWidth() / 2;
float halfHeight = getHeight() / 2;
float halfWidth = getWidth() / 2.0f;
float halfHeight = getHeight() / 2.0f;
progressRect.set(halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f, halfHeight * 1.25f);
biggerRect.set(progressRect);
int margin = 8;

View File

@ -102,7 +102,6 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
viewModel.refresh()
}
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
}
private fun onTopLoaded() {
@ -139,6 +138,10 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
}
}
override fun onViewReplyTo(position: Int) {
// there are no Reply to labels in conversations
}
override fun onOpenReblog(position: Int) {
// there are no reblogs in search results
}

View File

@ -0,0 +1,84 @@
package com.keylesspalace.tusky.components.notifications
import android.util.Log
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 javax.inject.Inject
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val notifier: Notifier
) {
fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
if (account.notificationsEnabled) {
try {
val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification ->
notifier.show(notification, account, index == 0)
}
accountManager.saveAccount(account)
} catch (e: Exception) {
Log.w(TAG, "Error while fetching notifications", e)
}
}
}
}
private fun fetchNotifications(account: AccountEntity): MutableList<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,
true,
Notification.Type.asStringList
).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)
}
}
return result
}
private 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")
notificationMarker
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch marker", e)
null
}
}
companion object {
const val TAG = "NotificationFetcher"
}
}

View File

@ -52,6 +52,7 @@ import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.ChatMessage;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.PollOption;
@ -90,6 +91,8 @@ public class NotificationHelper {
public static final String COMPOSE_ACTION = "COMPOSE_ACTION";
public static final String CHAT_REPLY_ACTION = "CHAT_REPLY_ACTION";
public static final String KEY_REPLY = "KEY_REPLY";
public static final String KEY_SENDER_ACCOUNT_ID = "KEY_SENDER_ACCOUNT_ID";
@ -112,6 +115,8 @@ public class NotificationHelper {
public static final String KEY_CITED_AUTHOR_LOCAL = "KEY_CITED_AUTHOR_LOCAL";
public static final String KEY_CHAT_ID = "KEY_CHAT_ID";
/**
* notification channels used on Android O+
**/
@ -122,6 +127,7 @@ public class NotificationHelper {
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_EMOJI_REACTION = "CHANNEL_EMOJI_REACTION";
public static final String CHANNEL_CHAT_MESSAGES = "CHANNEL_CHAT_MESSAGES";
/**
@ -132,7 +138,7 @@ public class NotificationHelper {
/**
* by setting this as false, it's possible to test legacy notification channels on newer devices
*/
//public static final boolean NOTIFICATION_USE_CHANNELS = false;
// public static final boolean NOTIFICATION_USE_CHANNELS = false;
public static final boolean NOTIFICATION_USE_CHANNELS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
/**
@ -151,13 +157,13 @@ public class NotificationHelper {
}
// Pleroma extension: don't notify about seen notifications
if (body.getPleroma() != null && body.getPleroma().getSeen() == true) {
if (body.getPleroma() != null && body.getPleroma().getSeen()) {
return;
}
if (body.getStatus() != null &&
(body.getStatus().isUserMuted() == true ||
body.getStatus().isThreadMuted() == true)) {
(body.getStatus().isUserMuted() ||
body.getStatus().isThreadMuted())) {
return;
}
@ -218,30 +224,45 @@ public class NotificationHelper {
builder.setLargeIcon(accountAvatar);
// Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat
if (body.getType() == Notification.Type.MENTION
&& android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
if(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if(body.getType() == Notification.Type.MENTION) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account);
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(REPLY_ACTION, context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
builder.addAction(quickReplyAction);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account);
PendingIntent composePendingIntent = getStatusReplyIntent(COMPOSE_ACTION, context, body, account);
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent)
.build();
NotificationCompat.Action composeAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_compose_shortcut), composePendingIntent)
.build();
builder.addAction(composeAction);
builder.addAction(composeAction);
} else if(body.getType() == Notification.Type.CHAT_MESSAGE) {
RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY)
.setLabel(context.getString(R.string.label_quick_reply))
.build();
PendingIntent quickReplyPendingIntent = getStatusReplyIntent(CHAT_REPLY_ACTION, context, body, account);
NotificationCompat.Action quickReplyAction =
new NotificationCompat.Action.Builder(R.drawable.ic_reply_24dp,
context.getString(R.string.action_quick_reply), quickReplyPendingIntent)
.addRemoteInput(replyRemoteInput)
.build();
builder.addAction(quickReplyAction);
}
}
builder.setSubText(account.getFullName());
@ -326,35 +347,40 @@ public class NotificationHelper {
}
private static PendingIntent getStatusReplyIntent(String action, Context context, Notification body, AccountEntity account) {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
Intent replyIntent = new Intent(context, SendStatusBroadcastReceiver.class)
.setAction(action)
.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
.putExtra(KEY_NOTIFICATION_ID, notificationId)
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
.setAction(action)
.putExtra(KEY_SENDER_ACCOUNT_ID, account.getId())
.putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier())
.putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName())
.putExtra(KEY_NOTIFICATION_ID, notificationId);
if(action == CHAT_REPLY_ACTION) {
replyIntent.putExtra(KEY_CHAT_ID, body.getChatMessage().getChatId());
} else {
Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString();
String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility();
String contentWarning = actionableStatus.getSpoilerText();
Status.Mention[] mentions = actionableStatus.getMentions();
List<String> mentionedUsernames = new ArrayList<>();
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
for (Status.Mention mention : mentions) {
mentionedUsernames.add(mention.getUsername());
}
mentionedUsernames.removeAll(Collections.singleton(account.getUsername()));
mentionedUsernames = new ArrayList<>(new LinkedHashSet<>(mentionedUsernames));
replyIntent.putExtra(KEY_CITED_AUTHOR_LOCAL, citedLocalAuthor)
.putExtra(KEY_CITED_TEXT, citedText)
.putExtra(KEY_CITED_STATUS_ID, inReplyToId)
.putExtra(KEY_VISIBILITY, replyVisibility)
.putExtra(KEY_SPOILER, contentWarning)
.putExtra(KEY_MENTIONS, mentionedUsernames.toArray(new String[0]));
}
return PendingIntent.getBroadcast(context.getApplicationContext(),
notificationId,
@ -374,7 +400,8 @@ public class NotificationHelper {
CHANNEL_BOOST + account.getIdentifier(),
CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(),
CHANNEL_EMOJI_REACTION + account.getIdentifier()
CHANNEL_EMOJI_REACTION + account.getIdentifier(),
CHANNEL_CHAT_MESSAGES + account.getIdentifier()
};
int[] channelNames = {
R.string.notification_mention_name,
@ -384,6 +411,7 @@ public class NotificationHelper {
R.string.notification_favourite_name,
R.string.notification_poll_name,
R.string.notification_emoji_name,
R.string.notification_chat_message_name,
};
int[] channelDescriptions = {
R.string.notification_mention_descriptions,
@ -392,7 +420,8 @@ public class NotificationHelper {
R.string.notification_boost_description,
R.string.notification_favourite_description,
R.string.notification_poll_description,
R.string.notification_emoji_description
R.string.notification_emoji_description,
R.string.notification_chat_message_description,
};
List<NotificationChannel> channels = new ArrayList<>(6);
@ -550,6 +579,8 @@ public class NotificationHelper {
return account.getNotificationsPolls();
case EMOJI_REACTION:
return account.getNotificationsEmojiReactions();
case CHAT_MESSAGE:
return account.getNotificationsChatMessages();
default:
return false;
}
@ -572,6 +603,8 @@ public class NotificationHelper {
return CHANNEL_POLL + account.getIdentifier();
case EMOJI_REACTION:
return CHANNEL_EMOJI_REACTION + account.getIdentifier();
case CHAT_MESSAGE:
return CHANNEL_CHAT_MESSAGES + account.getIdentifier();
default:
return null;
}
@ -653,6 +686,9 @@ public class NotificationHelper {
} else {
return context.getString(R.string.poll_ended_voted);
}
case CHAT_MESSAGE:
return String.format(context.getString(R.string.notification_chat_message_format),
accountName);
}
return null;
}
@ -680,12 +716,22 @@ public class NotificationHelper {
Poll poll = notification.getStatus().getPoll();
for(PollOption option: poll.getOptions()) {
builder.append(buildDescription(option.getTitle(),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotesCount()),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
context));
builder.append('\n');
}
return builder.toString();
}
case CHAT_MESSAGE:
if (!TextUtils.isEmpty(notification.getChatMessage().getContent())) {
return notification.getChatMessage().getContent().toString();
} else if(notification.getChatMessage().getAttachment() != null) {
return context.getString(notification.getChatMessage().getAttachment().describeAttachmentType());
} else if(notification.getChatMessage().getCard() != null) {
return context.getString(R.string.link);
} else {
return "";
}
}
return null;
}

View File

@ -16,83 +16,35 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import java.io.IOException
import javax.inject.Inject
class NotificationWorker(
private val context: Context,
context: Context,
params: WorkerParameters,
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
val accountList = accountManager.getAllAccountsOrderedByActive()
for (account in accountList) {
if (account.notificationsEnabled) {
try {
Log.d(TAG, "getting Notifications for " + account.fullName)
// don't care about withMuted because they are always silently ignored
val notificationsResponse = mastodonApi.notificationsWithAuth(
String.format("Bearer %s", account.accessToken),
account.domain, true
).execute()
val notifications = notificationsResponse.body()
if (notificationsResponse.isSuccessful && notifications != null) {
onNotificationsReceived(account, notifications)
} else {
Log.w(TAG, "error receiving notifications")
}
} catch (e: IOException) {
Log.w(TAG, "error receiving notifications", e)
}
}
}
notificationsFetcher.fetchAndShow()
return Result.success()
}
private fun onNotificationsReceived(account: AccountEntity, notificationList: List<Notification>) {
val newId = account.lastNotificationId
var newestId = ""
var isFirstOfBatch = true
notificationList.reversed().forEach { notification ->
val currentId = notification.id
if (newestId.isLessThan(currentId)) {
newestId = currentId
}
if (newId.isLessThan(currentId)) {
NotificationHelper.make(context, notification, account, isFirstOfBatch)
isFirstOfBatch = false
}
}
account.lastNotificationId = newestId
accountManager.saveAccount(account)
}
companion object {
private const val TAG = "NotificationWorker"
}
}
class NotificationWorkerFactory @Inject constructor(
val api: MastodonApi,
val accountManager: AccountManager
): WorkerFactory() {
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, api, accountManager)
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
}
return null
}

View File

@ -0,0 +1,20 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
/**
* Shows notifications.
*/
interface Notifier {
fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean)
}
class SystemNotifier(
private val context: Context
) : Notifier {
override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) {
NotificationHelper.make(context, notification, account, isFirstInBatch)
}
}

View File

@ -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.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.content.Intent
import android.graphics.drawable.Drawable
@ -72,6 +72,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
preference {
setTitle(R.string.title_tab_preferences)
icon = getTintedIcon(R.drawable.ic_tabs)
setOnPreferenceClickListener {
val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivity(intent)
@ -229,12 +230,27 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
preferenceCategory(R.string.pref_title_other) {
switchPreference {
key = PrefKeys.LIVE_NOTIFICATIONS
setTitle(R.string.pref_title_live_notifications)
setSummary(R.string.pref_summary_live_notifications)
isSingleLineTitle = false
isChecked = accountManager.activeAccount?.notificationsStreamingEnabled ?: false
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsStreamingEnabled = newValue as Boolean }
eventHub.dispatch(PreferenceChangedEvent(key))
true
}
}
}
preferenceCategory(R.string.pref_title_timeline_filters) {
preference {
setTitle(R.string.pref_title_public_filter_keywords)
setOnPreferenceClickListener {
launchFilterActivity(Filter.THREAD,
R.string.pref_title_thread_filter_keywords)
launchFilterActivity(Filter.PUBLIC,
R.string.pref_title_public_filter_keywords)
true
}
}

View File

@ -0,0 +1,258 @@
package com.keylesspalace.tusky.components.preference
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import okhttp3.OkHttpClient
import kotlin.system.exitProcess
/**
* This Preference lets the user select their preferred emoji font
*/
class EmojiPreference(
context: Context,
private val okHttpClient: OkHttpClient
) : Preference(context) {
private lateinit var selected: EmojiCompatFont
private lateinit var original: EmojiCompatFont
private val radioButtons = mutableListOf<RadioButton>()
private var updated = false
private var currentNeedsUpdate = false
private val downloadDisposables = MutableList<Disposable?>(FONTS.size) { null }
override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) {
super.onAttachedToHierarchy(preferenceManager)
// Find out which font is currently active
selected = EmojiCompatFont.byId(
PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0)
)
// We'll use this later to determine if anything has changed
original = selected
summary = selected.getDisplay(context)
}
override fun onClick() {
val view = LayoutInflater.from(context).inflate(R.layout.dialog_emojicompat, null)
viewIds.forEachIndexed { index, viewId ->
setupItem(view.findViewById(viewId), FONTS[index])
}
AlertDialog.Builder(context)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun setupItem(container: View, font: EmojiCompatFont) {
val title: TextView = container.findViewById(R.id.emojicompat_name)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val thumb: ImageView = container.findViewById(R.id.emojicompat_thumb)
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
val radio: RadioButton = container.findViewById(R.id.emojicompat_radio)
// Initialize all the views
title.text = font.getDisplay(container.context)
caption.setText(font.caption)
thumb.setImageResource(font.img)
// There needs to be a list of all the radio buttons in order to uncheck them when one is selected
radioButtons.add(radio)
updateItem(font, container)
// Set actions
download.setOnClickListener { startDownload(font, container) }
cancel.setOnClickListener { cancelDownload(font, container) }
radio.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) }
container.setOnClickListener { containerView: View ->
select(font, containerView.findViewById(R.id.emojicompat_radio))
}
}
private fun startDownload(font: EmojiCompatFont, container: View) {
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val progressBar: ProgressBar = container.findViewById(R.id.emojicompat_progress)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
// Switch to downloading style
download.visibility = View.GONE
caption.visibility = View.INVISIBLE
progressBar.visibility = View.VISIBLE
progressBar.progress = 0
cancel.visibility = View.VISIBLE
font.downloadFontFile(context, okHttpClient)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ progress ->
// The progress is returned as a float between 0 and 1, or -1 if it could not determined
if (progress >= 0) {
progressBar.isIndeterminate = false
val max = progressBar.max.toFloat()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress((max * progress).toInt(), true)
} else {
progressBar.progress = (max * progress).toInt()
}
} else {
progressBar.isIndeterminate = true
}
},
{
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
updateItem(font, container)
},
{
finishDownload(font, container)
}
).also { downloadDisposables[font.id] = it }
}
private fun cancelDownload(font: EmojiCompatFont, container: View) {
font.deleteDownloadedFile(container.context)
downloadDisposables[font.id]?.dispose()
downloadDisposables[font.id] = null
updateItem(font, container)
}
private fun finishDownload(font: EmojiCompatFont, container: View) {
select(font, container.findViewById(R.id.emojicompat_radio))
updateItem(font, container)
// Set the flag to restart the app (because an update has been downloaded)
if (selected === original && currentNeedsUpdate) {
updated = true
currentNeedsUpdate = false
}
}
/**
* Select a font both visually and logically
*
* @param font The font to be selected
* @param radio The radio button associated with it's visual item
*/
private fun select(font: EmojiCompatFont, radio: RadioButton) {
selected = font
// Uncheck all the other buttons
for (other in radioButtons) {
if (other !== radio) {
other.isChecked = false
}
}
radio.isChecked = true
}
/**
* Called when a "consistent" state is reached, i.e. it's not downloading the font
*
* @param font The font to be displayed
* @param container The ConstraintLayout containing the item
*/
private fun updateItem(font: EmojiCompatFont, container: View) {
// Assignments
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val progress: ProgressBar = container.findViewById(R.id.emojicompat_progress)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
val radio: RadioButton = container.findViewById(R.id.emojicompat_radio)
// There's no download going on
progress.visibility = View.GONE
cancel.visibility = View.GONE
caption.visibility = View.VISIBLE
if (font.isDownloaded(context)) {
// Make it selectable
download.visibility = View.GONE
radio.visibility = View.VISIBLE
container.isClickable = true
} else {
// Make it downloadable
download.visibility = View.VISIBLE
radio.visibility = View.GONE
container.isClickable = false
}
// Select it if necessary
if (font === selected) {
radio.isChecked = true
// Update available
if (!font.isDownloaded(context)) {
currentNeedsUpdate = true
}
} else {
radio.isChecked = false
}
}
private fun saveSelectedFont() {
val index = selected.id
Log.i(TAG, "saveSelectedFont: Font ID: $index")
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putInt(key, index)
.apply()
summary = selected.getDisplay(context)
}
/**
* User clicked ok -> save the selected font and offer to restart the app if something changed
*/
private fun onDialogOk() {
saveSelectedFont()
if (selected !== original || updated) {
AlertDialog.Builder(context)
.setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java)
val mPendingIntent = PendingIntent.getActivity(
context,
0x1f973, // This is the codepoint of the party face emoji :D
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set(
AlarmManager.RTC,
System.currentTimeMillis() + 100,
mPendingIntent)
exitProcess(0)
}.show()
}
}
companion object {
private const val TAG = "EmojiPreference"
// Please note that this array must sorted in the same way as the fonts.
private val viewIds = intArrayOf(
R.id.item_nomoji,
R.id.item_blobmoji,
R.id.item_twemoji,
R.id.item_notoemoji
)
}
}

View File

@ -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.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
@ -122,6 +122,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true
}
}
switchPreference {
setTitle(R.string.pref_title_notification_filter_chat_messages)
key = PrefKeys.NOTIFICATION_FILTER_CHAT_MESSAGES
isIconSpaceReserved = false
isChecked = activeAccount.notificationsChatMessages
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsChatMessages = newValue as Boolean }
true
}
}
}
preferenceCategory(R.string.pref_title_notification_alerts) { category ->

View File

@ -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
package com.keylesspalace.tusky.components.preference
import android.content.Context
import android.content.Intent
@ -23,9 +23,12 @@ import android.util.Log
import android.view.MenuItem
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.fragment.preference.*
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
import dagger.android.DispatchingAndroidInjector
@ -129,8 +132,8 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
}
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars",
"useBlurhash", "showCardsInTimelines", "confirmReblogs", "hideMutedUsers",
"enableSwipeForTabs", "bigEmojis", "mainNavPosition" -> {
"useBlurhash", "showCardsInTimelines", "confirmReblogs", "hideMutedUsers",
"enableSwipeForTabs", "bigEmojis", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR -> {
restartActivitiesOnExit = true
}
"language" -> {

View File

@ -13,13 +13,13 @@
* 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.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.PreferencesActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.settings.*
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
@ -27,8 +27,13 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
import okhttp3.OkHttpClient
import javax.inject.Inject
class PreferencesFragment : PreferenceFragmentCompat() {
class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var okhttpclient: OkHttpClient
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
private var httpProxyPref: Preference? = null
@ -47,7 +52,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
}
emojiPreference {
emojiPreference(okhttpclient) {
setDefaultValue("system_default")
setIcon(R.drawable.ic_emoji_24dp)
key = PrefKeys.EMOJI
@ -85,6 +90,12 @@ class PreferencesFragment : PreferenceFragmentCompat() {
setTitle(R.string.pref_main_nav_position)
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.HIDE_TOP_TOOLBAR
setTitle(R.string.pref_title_hide_top_toolbar)
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.FAB_HIDE
@ -125,6 +136,13 @@ class PreferencesFragment : PreferenceFragmentCompat() {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.SHOW_CARDS_IN_TIMELINES
setTitle(R.string.pref_title_show_cards_in_timelines)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
@ -141,8 +159,8 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.SHOW_CARDS_IN_TIMELINES
setDefaultValue(true)
key = PrefKeys.CONFIRM_REBLOGS
setTitle(R.string.pref_title_confirm_reblogs)
isSingleLineTitle = false
}
@ -169,6 +187,15 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
}
preferenceCategory(R.string.pref_title_privacy) {
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANONYMIZE_FILENAMES
setTitle(R.string.pref_title_anonymize_upload_filenames)
isSingleLineTitle = false
}
}
preferenceCategory(R.string.pref_title_browser_settings) {
switchPreference {
setDefaultValue(false)

View File

@ -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.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat

View File

@ -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.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat

View File

@ -19,6 +19,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.components.report.adapter.StatusesRepository
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Relationship
@ -31,6 +34,7 @@ import javax.inject.Inject
class ReportViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val statusesRepository: StatusesRepository) : RxAwareViewModel() {
private val navigationMutable = MutableLiveData<Screen>()
@ -123,7 +127,8 @@ class ReportViewModel @Inject constructor(
}
fun toggleMute() {
if (muteStateMutable.value?.data == true) {
val alreadyMuted = muteStateMutable.value?.data == true
if (alreadyMuted) {
mastodonApi.unmuteAccountObservable(accountId)
} else {
mastodonApi.muteAccountObservable(accountId)
@ -132,7 +137,11 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
muteStateMutable.value = Success(relationship?.muting == true)
val muting = relationship?.muting == true
muteStateMutable.value = Success(muting)
if (muting) {
eventHub.dispatch(MuteEvent(accountId))
}
},
{ error ->
muteStateMutable.value = Error(false, error.message)
@ -143,7 +152,8 @@ class ReportViewModel @Inject constructor(
}
fun toggleBlock() {
if (blockStateMutable.value?.data == true) {
val alreadyBlocked = blockStateMutable.value?.data == true
if (alreadyBlocked) {
mastodonApi.unblockAccountObservable(accountId)
} else {
mastodonApi.blockAccountObservable(accountId)
@ -152,7 +162,11 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
blockStateMutable.value = Success(relationship?.blocking == true)
val blocking = relationship?.blocking == true
blockStateMutable.value = Success(blocking)
if (blocking) {
eventHub.dispatch(BlockEvent(accountId))
}
},
{ error ->
blockStateMutable.value = Error(false, error.message)

View File

@ -101,7 +101,6 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
private fun setupSwipeRefreshLayout() {
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
swipeRefreshLayout.setOnRefreshListener {
snackbarErrorRetry?.dismiss()

View File

@ -59,8 +59,6 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
ThemeUtils.getColor(this, android.R.attr.colorBackground))
scheduledTootList.setHasFixedSize(true)
scheduledTootList.layoutManager = LinearLayoutManager(this)

View File

@ -58,9 +58,6 @@ abstract class SearchFragment<T> : Fragment(),
private fun setupSwipeRefreshLayout() {
swipeRefreshLayout.setOnRefreshListener(this)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground)
)
}
private fun subscribeObservables() {

View File

@ -155,6 +155,13 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
}
override fun onViewReplyTo(position: Int) {
searchAdapter.getItem(position)?.first?.let { status ->
val actionableStatus = status.actionableStatus
bottomSheetActivity?.viewThread(actionableStatus.inReplyToId!!, null)
}
}
override fun onOpenReblog(position: Int) {
searchAdapter.getItem(position)?.first?.let { status ->
bottomSheetActivity?.viewAccount(status.account.id)
@ -195,10 +202,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
}
private fun onShowReplyTo(replyToId: String) {
bottomSheetActivity?.viewThread(replyToId, null)
}
companion object {
fun newInstance() = SearchStatusesFragment()
}
@ -272,10 +275,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
}
openAsItem.title = openAsTitle
if(status.inReplyToId == null) {
popup.menu.findItem(R.id.status_reply_to)?.isVisible = false
}
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.status_share_content -> {
@ -309,10 +308,6 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
LinkHelper.openLinkInBrowser(Uri.parse(statusUrl), context);
return@setOnMenuItemClickListener true
}
R.id.status_reply_to -> {
onShowReplyTo(status.inReplyToId!!)
return@setOnMenuItemClickListener true
}
R.id.status_open_as -> {
showOpenAsDialog(statusUrl!!, item.title)
return@setOnMenuItemClickListener true

View File

@ -37,6 +37,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var displayName: String = "",
var profilePictureUrl: String = "",
var notificationsEnabled: Boolean = true,
var notificationsStreamingEnabled: Boolean = true,
var notificationsMentioned: Boolean = true,
var notificationsFollowed: Boolean = true,
var notificationsFollowRequested: Boolean = false,
@ -44,6 +45,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationsEmojiReactions: Boolean = true,
var notificationsChatMessages: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,
@ -56,7 +58,7 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var activeNotifications: String = "[]",
var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs(),
var notificationsFilter: String = "[\"follow_request\"]",
var notificationsFilter: String = "[]",
var defaultFormattingSyntax: String = "") {
val identifier: String

Some files were not shown because too many files have changed in this diff Show More