Merge remote-tracking branch 'tuskyapp/develop'
This commit is contained in:
commit
95a1f5632b
|
@ -103,6 +103,8 @@ ext.okhttpVersion = '4.9.3'
|
|||
ext.glideVersion = '4.13.1'
|
||||
ext.daggerVersion = '2.41'
|
||||
ext.materialdrawerVersion = '8.4.5'
|
||||
ext.emoji2_version = '1.1.0'
|
||||
ext.filemojicompat_version = '3.2.1'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
|
@ -125,8 +127,9 @@ dependencies {
|
|||
implementation "androidx.cardview:cardview:1.0.0"
|
||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||
implementation "androidx.sharetarget:sharetarget:1.2.0-rc01"
|
||||
implementation "androidx.emoji:emoji:1.1.0"
|
||||
implementation "androidx.emoji:emoji-appcompat:1.1.0"
|
||||
implementation "androidx.emoji2:emoji2:$emoji2_version"
|
||||
implementation "androidx.emoji2:emoji2-views:$emoji2_version"
|
||||
implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
|
||||
|
@ -137,7 +140,6 @@ dependencies {
|
|||
implementation "androidx.work:work-runtime:2.7.1"
|
||||
implementation "androidx.room:room-ktx:$roomVersion"
|
||||
implementation "androidx.room:room-paging:$roomVersion"
|
||||
implementation "androidx.room:room-rxjava3:$roomVersion"
|
||||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
|
||||
|
||||
|
@ -184,7 +186,9 @@ dependencies {
|
|||
|
||||
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0"
|
||||
|
||||
implementation "de.c1710:filemojicompat:1.0.18"
|
||||
implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
|
||||
implementation "de.c1710:filemojicompat:$filemojicompat_version"
|
||||
implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version"
|
||||
|
||||
testImplementation "androidx.test.ext:junit:1.1.3"
|
||||
testImplementation "org.robolectric:robolectric:4.4"
|
||||
|
|
|
@ -0,0 +1,815 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 34,
|
||||
"identityHash": "7f766d68ab5d72a7988cd81c183e9a9d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "DraftEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "inReplyToId",
|
||||
"columnName": "inReplyToId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentWarning",
|
||||
"columnName": "contentWarning",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sensitive",
|
||||
"columnName": "sensitive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "failedToSend",
|
||||
"columnName": "failedToSend",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "AccountEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `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)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domain",
|
||||
"columnName": "domain",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessToken",
|
||||
"columnName": "accessToken",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isActive",
|
||||
"columnName": "isActive",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "profilePictureUrl",
|
||||
"columnName": "profilePictureUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsMentioned",
|
||||
"columnName": "notificationsMentioned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFollowed",
|
||||
"columnName": "notificationsFollowed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFollowRequested",
|
||||
"columnName": "notificationsFollowRequested",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsReblogged",
|
||||
"columnName": "notificationsReblogged",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsFavorited",
|
||||
"columnName": "notificationsFavorited",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsPolls",
|
||||
"columnName": "notificationsPolls",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSubscriptions",
|
||||
"columnName": "notificationsSubscriptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsSignUps",
|
||||
"columnName": "notificationsSignUps",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsUpdates",
|
||||
"columnName": "notificationsUpdates",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "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
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_AccountEntity_domain_accountId",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"domain",
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "InstanceEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "instance",
|
||||
"columnName": "instance",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojiList",
|
||||
"columnName": "emojiList",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maximumTootCharacters",
|
||||
"columnName": "maximumTootCharacters",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptions",
|
||||
"columnName": "maxPollOptions",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollOptionLength",
|
||||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "minPollDuration",
|
||||
"columnName": "minPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "maxPollDuration",
|
||||
"columnName": "maxPollDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "charactersReservedPerUrl",
|
||||
"columnName": "charactersReservedPerUrl",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"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 NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, 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": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "visibility",
|
||||
"columnName": "visibility",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "mentions",
|
||||
"columnName": "mentions",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tags",
|
||||
"columnName": "tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "application",
|
||||
"columnName": "application",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogServerId",
|
||||
"columnName": "reblogServerId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "reblogAccountId",
|
||||
"columnName": "reblogAccountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "poll",
|
||||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "muted",
|
||||
"columnName": "muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "expanded",
|
||||
"columnName": "expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentCollapsed",
|
||||
"columnName": "contentCollapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentShowing",
|
||||
"columnName": "contentShowing",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinned",
|
||||
"columnName": "pinned",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "TimelineAccountEntity",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"authorServerId",
|
||||
"timelineUserId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"serverId",
|
||||
"timelineUserId"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "TimelineAccountEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "serverId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timelineUserId",
|
||||
"columnName": "timelineUserId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localUsername",
|
||||
"columnName": "localUsername",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "username",
|
||||
"columnName": "username",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatar",
|
||||
"columnName": "avatar",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "emojis",
|
||||
"columnName": "emojis",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bot",
|
||||
"columnName": "bot",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"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_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, 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.tags",
|
||||
"columnName": "s_tags",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.showingHiddenContent",
|
||||
"columnName": "s_showingHiddenContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.expanded",
|
||||
"columnName": "s_expanded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.collapsed",
|
||||
"columnName": "s_collapsed",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.muted",
|
||||
"columnName": "s_muted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastStatus.poll",
|
||||
"columnName": "s_poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id",
|
||||
"accountId"
|
||||
],
|
||||
"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, '7f766d68ab5d72a7988cd81c183e9a9d')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -44,8 +44,7 @@ import androidx.appcompat.widget.PopupMenu
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.emoji.text.EmojiCompat.InitCallback
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
|
@ -129,6 +128,7 @@ import com.mikepenz.materialdrawer.util.updateBadge
|
|||
import com.mikepenz.materialdrawer.widget.AccountHeaderView
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
|
@ -177,13 +177,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
|
||||
private var accountLocked: Boolean = false
|
||||
|
||||
private val emojiInitCallback = object : InitCallback() {
|
||||
override fun onInitialized() {
|
||||
if (!isDestroyed) {
|
||||
updateProfiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
// We need to know if the emoji pack has been changed
|
||||
private var selectedEmojiPack: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -308,6 +303,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
// Flush old media that was cached for sharing
|
||||
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
|
||||
}
|
||||
|
||||
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -318,11 +315,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
NotificationHelper.clearNotificationsForActiveAccount(this, accountManager)
|
||||
val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
|
||||
if (currentEmojiPack != selectedEmojiPack) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"onResume: EmojiPack has been changed from %s to %s"
|
||||
.format(selectedEmojiPack, currentEmojiPack)
|
||||
)
|
||||
selectedEmojiPack = currentEmojiPack
|
||||
recreate()
|
||||
}
|
||||
streamingManager.resume()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// For some reason the navigation drawer is opened when the activity is recreated
|
||||
if (binding.mainDrawerLayout.isOpen) {
|
||||
binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false)
|
||||
}
|
||||
keepScreenOn()
|
||||
}
|
||||
|
||||
|
@ -394,11 +405,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
EmojiCompat.get().unregisterInitCallback(emojiInitCallback)
|
||||
}
|
||||
|
||||
private fun forwardShare(intent: Intent) {
|
||||
val composeIntent = Intent(this, ComposeActivity::class.java)
|
||||
composeIntent.action = intent.action
|
||||
|
@ -604,8 +610,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
textColor = ColorStateList.valueOf(ThemeUtils.getColor(this@MainActivity, R.attr.colorInfo))
|
||||
}
|
||||
)
|
||||
|
||||
EmojiCompat.get().registerInitCallback(emojiInitCallback)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
@ -962,18 +966,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
}
|
||||
|
||||
private fun fetchAnnouncements() {
|
||||
mastodonApi.listAnnouncements(false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
|
||||
.subscribe(
|
||||
{ announcements ->
|
||||
unreadAnnouncementsCount = announcements.count { !it.read }
|
||||
updateAnnouncementsBadge()
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to fetch announcements.", it)
|
||||
}
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
mastodonApi.listAnnouncements(false)
|
||||
.fold(
|
||||
{ announcements ->
|
||||
unreadAnnouncementsCount = announcements.count { !it.read }
|
||||
updateAnnouncementsBadge()
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w(TAG, "Failed to fetch announcements.", throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAnnouncementsBadge() {
|
||||
|
@ -983,11 +987,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
|
|||
private fun updateProfiles() {
|
||||
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
||||
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
|
||||
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
|
||||
|
||||
ProfileDrawerItem().apply {
|
||||
isSelected = acc.isActive
|
||||
nameText = emojifiedName
|
||||
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
|
||||
iconUrl = acc.profilePictureUrl
|
||||
isNameShown = true
|
||||
identifier = acc.id
|
||||
|
|
|
@ -19,18 +19,18 @@ import android.app.Application
|
|||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.WorkManager
|
||||
import autodispose2.AutoDisposePlugins
|
||||
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
|
||||
import com.keylesspalace.tusky.di.AppInjector
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont
|
||||
import com.keylesspalace.tusky.util.LocaleManager
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import dagger.android.DispatchingAndroidInjector
|
||||
import dagger.android.HasAndroidInjector
|
||||
import de.c1710.filemojicompat_defaults.DefaultEmojiPackList
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper
|
||||
import de.c1710.filemojicompat_ui.helpers.EmojiPreference
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.conscrypt.Conscrypt
|
||||
import java.security.Security
|
||||
|
@ -65,12 +65,10 @@ class TuskyApplication : Application(), HasAndroidInjector {
|
|||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
// init the custom emoji fonts
|
||||
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0)
|
||||
val emojiConfig = EmojiCompatFont.byId(emojiSelection)
|
||||
.getConfig(this)
|
||||
.setReplaceAll(true)
|
||||
EmojiCompat.init(emojiConfig)
|
||||
// In this case, we want to have the emoji preferences merged with the other ones
|
||||
// Copied from PreferenceManager.getDefaultSharedPreferenceName
|
||||
EmojiPreference.sharedPreferenceName = packageName + "_preferences"
|
||||
EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false)
|
||||
|
||||
// init night mode
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
|
|
|
@ -45,8 +45,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
|
|||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
|
||||
if (showBotOverlay && account.getBot()) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setImageResource(R.drawable.ic_bot_24dp);
|
||||
avatarInset.setBackgroundColor(0x50ffffff);
|
||||
avatarInset.setImageResource(R.drawable.bot_badge);
|
||||
} else {
|
||||
avatarInset.setVisibility(View.GONE);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ import android.widget.Button;
|
|||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorRes;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
|
@ -49,6 +51,7 @@ import com.keylesspalace.tusky.entity.TimelineAccount;
|
|||
import com.keylesspalace.tusky.interfaces.AccountActionListener;
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
|
@ -62,10 +65,8 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|||
|
||||
import net.accelf.yuito.QuoteInlineHelper;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
|
@ -94,6 +95,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
private NotificationActionListener notificationActionListener;
|
||||
private AccountActionListener accountActionListener;
|
||||
private AdapterDataSource<NotificationViewData> dataSource;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
||||
public NotificationsAdapter(String accountId,
|
||||
AdapterDataSource<NotificationViewData> dataSource,
|
||||
|
@ -123,7 +125,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_notification, parent, false);
|
||||
return new StatusNotificationViewHolder(view, statusDisplayOptions);
|
||||
return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
View view = inflater
|
||||
|
@ -182,8 +184,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
case VIEW_TYPE_STATUS: {
|
||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
|
||||
holder.setupWithStatus(status,
|
||||
statusListener, statusDisplayOptions, payloadForHolder);
|
||||
if (status == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showStatusContent(false);
|
||||
} else {
|
||||
if (payloads == null) {
|
||||
holder.showStatusContent(true);
|
||||
}
|
||||
holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder);
|
||||
}
|
||||
if (concreteNotificaton.getType() == Notification.Type.POLL) {
|
||||
holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId()));
|
||||
} else {
|
||||
|
@ -196,6 +206,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData();
|
||||
if (payloadForHolder == null) {
|
||||
if (statusViewData == null) {
|
||||
/* in some very rare cases servers sends null status even though they should not,
|
||||
* we have to handle it somehow */
|
||||
holder.showNotificationContent(false);
|
||||
} else {
|
||||
holder.showNotificationContent(true);
|
||||
|
@ -205,7 +217,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
holder.setUsername(status.getAccount().getUsername());
|
||||
holder.setCreatedAt(status.getCreatedAt());
|
||||
|
||||
if (concreteNotificaton.getType() == Notification.Type.STATUS) {
|
||||
if (concreteNotificaton.getType() == Notification.Type.STATUS ||
|
||||
concreteNotificaton.getType() == Notification.Type.UPDATE) {
|
||||
holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot());
|
||||
} else {
|
||||
holder.setAvatars(status.getAccount().getAvatar(),
|
||||
|
@ -285,7 +298,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
case STATUS:
|
||||
case FAVOURITE:
|
||||
case REBLOG: {
|
||||
case REBLOG:
|
||||
case UPDATE: {
|
||||
return VIEW_TYPE_STATUS_NOTIFICATION;
|
||||
}
|
||||
case FOLLOW:
|
||||
|
@ -389,19 +403,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
||||
private ConstraintLayout quoteContainer;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter;
|
||||
|
||||
private String accountId;
|
||||
private String notificationId;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private StatusViewData.Concrete statusViewData;
|
||||
private SimpleDateFormat shortSdf;
|
||||
private SimpleDateFormat longSdf;
|
||||
|
||||
private int avatarRadius48dp;
|
||||
private int avatarRadius36dp;
|
||||
private int avatarRadius24dp;
|
||||
|
||||
StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||
StatusNotificationViewHolder(
|
||||
View itemView,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
AbsoluteTimeFormatter absoluteTimeFormatter
|
||||
) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_top_text);
|
||||
statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||
|
@ -415,6 +432,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.absoluteTimeFormatter = absoluteTimeFormatter;
|
||||
|
||||
quoteContainer = itemView.findViewById(R.id.status_quote_inline_container);
|
||||
|
||||
|
@ -425,8 +443,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
itemView.setOnClickListener(this);
|
||||
message.setOnClickListener(this);
|
||||
statusContent.setOnClickListener(this);
|
||||
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
||||
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
|
||||
|
||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
|
@ -456,17 +472,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
|
||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
String time;
|
||||
if (createdAt != null) {
|
||||
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
|
||||
time = longSdf.format(createdAt);
|
||||
} else {
|
||||
time = shortSdf.format(createdAt);
|
||||
}
|
||||
} else {
|
||||
time = "??:??:??";
|
||||
}
|
||||
timestampInfo.setText(time);
|
||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||
} else {
|
||||
// This is the visible timestampInfo.
|
||||
String readout;
|
||||
|
@ -490,6 +496,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) {
|
||||
Drawable icon = ContextCompat.getDrawable(context, drawable);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
|
||||
this.statusViewData = notificationViewData.getStatusViewData();
|
||||
|
||||
|
@ -502,35 +516,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
switch (type) {
|
||||
default:
|
||||
case FAVOURITE: {
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(ContextCompat.getColor(context,
|
||||
R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
|
||||
icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange);
|
||||
format = context.getString(R.string.notification_favourite_format);
|
||||
break;
|
||||
}
|
||||
case REBLOG: {
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(ContextCompat.getColor(context,
|
||||
R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
|
||||
icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_reblog_format);
|
||||
break;
|
||||
}
|
||||
case STATUS: {
|
||||
icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp);
|
||||
if (icon != null) {
|
||||
icon.setColorFilter(ContextCompat.getColor(context,
|
||||
R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP);
|
||||
}
|
||||
|
||||
icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_subscription_format);
|
||||
break;
|
||||
}
|
||||
case UPDATE: {
|
||||
icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue);
|
||||
format = context.getString(R.string.notification_update_format);
|
||||
break;
|
||||
}
|
||||
}
|
||||
message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null);
|
||||
String wholeMessage = String.format(format, displayName);
|
||||
|
@ -579,9 +583,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
notificationAvatar.setVisibility(View.VISIBLE);
|
||||
notificationAvatar.setBackgroundColor(0x50ffffff);
|
||||
Glide.with(notificationAvatar)
|
||||
.load(R.drawable.ic_bot_24dp)
|
||||
.load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge))
|
||||
.into(notificationAvatar);
|
||||
|
||||
} else {
|
||||
|
|
|
@ -19,7 +19,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.emoji2.text.EmojiCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.ItemPollBinding
|
||||
|
|
|
@ -21,6 +21,7 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
@ -28,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
|
@ -42,6 +44,7 @@ import com.keylesspalace.tusky.entity.Emoji;
|
|||
import com.keylesspalace.tusky.entity.HashTag;
|
||||
import com.keylesspalace.tusky.entity.Status;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter;
|
||||
import com.keylesspalace.tusky.util.CardViewMode;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
|
@ -58,10 +61,8 @@ import com.keylesspalace.tusky.viewdata.StatusViewData;
|
|||
import net.accelf.yuito.QuoteInlineHelper;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import at.connyduck.sparkbutton.SparkButton;
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
@ -82,6 +83,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private ImageButton quoteButton;
|
||||
private SparkButton bookmarkButton;
|
||||
private ImageButton moreButton;
|
||||
private ConstraintLayout mediaContainer;
|
||||
protected MediaPreviewImageView[] mediaPreviews;
|
||||
private ImageView[] mediaOverlays;
|
||||
private TextView sensitiveMediaWarning;
|
||||
|
@ -110,10 +112,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private TextView cardUrl;
|
||||
private PollAdapter pollAdapter;
|
||||
|
||||
private SimpleDateFormat shortSdf;
|
||||
private SimpleDateFormat longSdf;
|
||||
|
||||
private final NumberFormat numberFormat = NumberFormat.getNumberInstance();
|
||||
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
|
||||
|
||||
protected int avatarRadius48dp;
|
||||
private int avatarRadius36dp;
|
||||
|
@ -135,7 +135,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
bookmarkButton = itemView.findViewById(R.id.status_bookmark);
|
||||
moreButton = itemView.findViewById(R.id.status_more);
|
||||
|
||||
itemView.findViewById(R.id.status_media_preview_container).setClipToOutline(true);
|
||||
mediaContainer = itemView.findViewById(R.id.status_media_preview_container);
|
||||
mediaContainer.setClipToOutline(true);
|
||||
|
||||
mediaPreviews = new MediaPreviewImageView[]{
|
||||
itemView.findViewById(R.id.status_media_preview_0),
|
||||
|
@ -180,9 +181,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext()));
|
||||
((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false);
|
||||
|
||||
this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
||||
this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
|
||||
|
||||
this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp);
|
||||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
|
@ -300,11 +298,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackgroundColor(0x50ffffff);
|
||||
Glide.with(avatarInset)
|
||||
.load(R.drawable.ic_bot_24dp)
|
||||
// passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692
|
||||
.load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge))
|
||||
.into(avatarInset);
|
||||
|
||||
} else {
|
||||
avatarInset.setVisibility(View.GONE);
|
||||
}
|
||||
|
@ -330,7 +327,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
timestampInfo.setText(getAbsoluteTime(createdAt));
|
||||
timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true));
|
||||
} else {
|
||||
if (createdAt == null) {
|
||||
timestampInfo.setText("?m");
|
||||
|
@ -343,21 +340,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private String getAbsoluteTime(Date createdAt) {
|
||||
if (createdAt == null) {
|
||||
return "??:??:??";
|
||||
}
|
||||
if (DateUtils.isToday(createdAt.getTime())) {
|
||||
return shortSdf.format(createdAt);
|
||||
} else {
|
||||
return longSdf.format(createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
private CharSequence getCreatedAtDescription(Date createdAt,
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
return getAbsoluteTime(createdAt);
|
||||
return absoluteTimeFormatter.format(createdAt, true);
|
||||
} else {
|
||||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
|
@ -844,9 +830,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull final StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
if (payloads == null) {
|
||||
Status actionable = status.getActionable();
|
||||
|
@ -1139,7 +1125,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
return votesText;
|
||||
} else {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt()));
|
||||
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false));
|
||||
} else {
|
||||
pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
|
||||
}
|
||||
|
@ -1261,6 +1247,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
}
|
||||
|
||||
public void showStatusContent(boolean show) {
|
||||
int visibility = show ? View.VISIBLE : View.GONE;
|
||||
avatar.setVisibility(visibility);
|
||||
avatarInset.setVisibility(visibility);
|
||||
displayName.setVisibility(visibility);
|
||||
username.setVisibility(visibility);
|
||||
timestampInfo.setVisibility(visibility);
|
||||
contentWarningDescription.setVisibility(visibility);
|
||||
contentWarningButton.setVisibility(visibility);
|
||||
content.setVisibility(visibility);
|
||||
cardView.setVisibility(visibility);
|
||||
mediaContainer.setVisibility(visibility);
|
||||
pollOptions.setVisibility(visibility);
|
||||
pollButton.setVisibility(visibility);
|
||||
pollDescription.setVisibility(visibility);
|
||||
replyButton.setVisibility(visibility);
|
||||
reblogButton.setVisibility(visibility);
|
||||
favouriteButton.setVisibility(visibility);
|
||||
bookmarkButton.setVisibility(visibility);
|
||||
moreButton.setVisibility(visibility);
|
||||
}
|
||||
|
||||
private static String formatDuration(double durationInSeconds) {
|
||||
int seconds = (int) Math.round(durationInSeconds) % 60;
|
||||
int minutes = (int) durationInSeconds % 3600 / 60;
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
|
@ -105,10 +103,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void setupWithStatus(final StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
public void setupWithStatus(@NonNull final StatusViewData.Concrete status,
|
||||
@NonNull final StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
|
||||
if (payloads == null) {
|
||||
|
@ -121,20 +119,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
setApplication(status.getActionable().getApplication());
|
||||
|
||||
View.OnLongClickListener longClickListener = view -> {
|
||||
TextView textView = (TextView) view;
|
||||
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clip = ClipData.newPlainText("toot", textView.getText());
|
||||
clipboard.setPrimaryClip(clip);
|
||||
|
||||
Toast.makeText(view.getContext(), R.string.copy_to_clipboard_success, Toast.LENGTH_SHORT).show();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
content.setOnLongClickListener(longClickListener);
|
||||
contentWarningDescription.setOnLongClickListener(longClickListener);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import android.view.View;
|
|||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
|
@ -58,9 +59,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void setupWithStatus(StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
public void setupWithStatus(@NonNull StatusViewData.Concrete status,
|
||||
@NonNull final StatusActionListener listener,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
if (payloads == null) {
|
||||
|
||||
|
@ -129,4 +130,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
content.setFilters(NO_INPUT_FILTER);
|
||||
}
|
||||
}
|
||||
|
||||
public void showStatusContent(boolean show) {
|
||||
super.showStatusContent(show);
|
||||
contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.view.Menu
|
||||
|
@ -39,7 +37,7 @@ import androidx.core.view.WindowCompat
|
|||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.emoji2.text.EmojiCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
|
@ -379,12 +377,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
.show()
|
||||
}
|
||||
}
|
||||
viewModel.accountFieldData.observe(
|
||||
this
|
||||
) {
|
||||
accountFieldAdapter.fields = it
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
}
|
||||
viewModel.noteSaved.observe(this) {
|
||||
binding.saveNoteInfo.visible(it, View.INVISIBLE)
|
||||
}
|
||||
|
@ -416,7 +408,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
|
||||
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
|
||||
|
||||
// accountFieldAdapter.fields = account.fields ?: emptyList()
|
||||
accountFieldAdapter.fields = account.fields ?: emptyList()
|
||||
accountFieldAdapter.emojis = account.emojis ?: emptyList()
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
|
@ -504,13 +496,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar)
|
||||
|
||||
binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name)
|
||||
|
||||
// this is necessary because API 19 can't handle vector compound drawables
|
||||
val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate()
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
package com.keylesspalace.tusky.components.account
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
@ -23,11 +22,8 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.createClickableText
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
|
@ -38,7 +34,7 @@ class AccountFieldAdapter(
|
|||
) : RecyclerView.Adapter<BindingHolder<ItemAccountFieldBinding>>() {
|
||||
|
||||
var emojis: List<Emoji> = emptyList()
|
||||
var fields: List<Either<IdentityProof, Field>> = emptyList()
|
||||
var fields: List<Field> = emptyList()
|
||||
|
||||
override fun getItemCount() = fields.size
|
||||
|
||||
|
@ -48,32 +44,20 @@ class AccountFieldAdapter(
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAccountFieldBinding>, position: Int) {
|
||||
val proofOrField = fields[position]
|
||||
val field = fields[position]
|
||||
val nameTextView = holder.binding.accountFieldName
|
||||
val valueTextView = holder.binding.accountFieldValue
|
||||
|
||||
if (proofOrField.isLeft()) {
|
||||
val identityProof = proofOrField.asLeft()
|
||||
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
|
||||
nameTextView.text = emojifiedName
|
||||
|
||||
nameTextView.text = identityProof.provider
|
||||
valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl)
|
||||
|
||||
valueTextView.movementMethod = LinkMovementMethod.getInstance()
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
|
||||
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
|
||||
|
||||
if (field.verifiedAt != null) {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
} else {
|
||||
val field = proofOrField.asRight()
|
||||
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
|
||||
nameTextView.text = emojifiedName
|
||||
|
||||
val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
|
||||
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
|
||||
|
||||
if (field.verifiedAt != null) {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
} else {
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,17 +10,13 @@ import com.keylesspalace.tusky.appstore.ProfileEditedEvent
|
|||
import com.keylesspalace.tusky.appstore.UnfollowEvent
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.combineOptionalLiveData
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import retrofit2.Call
|
||||
|
@ -40,13 +36,6 @@ class AccountViewModel @Inject constructor(
|
|||
|
||||
val noteSaved = MutableLiveData<Boolean>()
|
||||
|
||||
private val identityProofData = MutableLiveData<List<IdentityProof>>()
|
||||
|
||||
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
|
||||
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
|
||||
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
|
||||
}
|
||||
|
||||
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
|
||||
private var isDataLoading = false
|
||||
|
||||
|
@ -106,22 +95,6 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun obtainIdentityProof(reload: Boolean = false) {
|
||||
if (identityProofData.value == null || reload) {
|
||||
|
||||
mastodonApi.identityProofs(accountId)
|
||||
.subscribe(
|
||||
{ proofs ->
|
||||
identityProofData.postValue(proofs)
|
||||
},
|
||||
{ t ->
|
||||
Log.w(TAG, "failed obtaining identity proofs", t)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeFollowState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
if (relationship?.following == true || relationship?.requested == true) {
|
||||
|
@ -314,7 +287,6 @@ class AccountViewModel @Inject constructor(
|
|||
return
|
||||
accountId.let {
|
||||
obtainAccount(isReload)
|
||||
obtainIdentityProof()
|
||||
if (!isSelf)
|
||||
obtainRelationship(isReload)
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener
|
|||
import com.keylesspalace.tusky.util.BindingHolder
|
||||
import com.keylesspalace.tusky.util.EmojiSpan
|
||||
import com.keylesspalace.tusky.util.emojify
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.setClickableText
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
|
@ -60,7 +61,7 @@ class AnnouncementAdapter(
|
|||
val chips = holder.binding.chipGroup
|
||||
val addReactionChip = holder.binding.addReactionChip
|
||||
|
||||
val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis)
|
||||
val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis)
|
||||
|
||||
setClickableText(text, emojifiedText, item.mentions, item.tags, listener)
|
||||
|
||||
|
|
|
@ -18,32 +18,26 @@ package com.keylesspalace.tusky.components.announcements
|
|||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceEntity
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.entity.Announcement
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class AnnouncementsViewModel @Inject constructor(
|
||||
accountManager: AccountManager,
|
||||
private val appDatabase: AppDatabase,
|
||||
private val instanceInfoRepo: InstanceInfoRepository,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val eventHub: EventHub
|
||||
) : RxAwareViewModel() {
|
||||
) : ViewModel() {
|
||||
|
||||
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
|
||||
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
|
||||
|
@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor(
|
|||
val emojis: LiveData<List<Emoji>> = emojisMutable
|
||||
|
||||
init {
|
||||
Single.zip(
|
||||
mastodonApi.getCustomEmojis(),
|
||||
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
|
||||
.onErrorResumeNext {
|
||||
rxSingle {
|
||||
mastodonApi.getInstance().getOrThrow()
|
||||
}.map { Either.Right(it) }
|
||||
}
|
||||
) { emojis, either ->
|
||||
either.asLeftOrNull()?.copy(emojiList = emojis)
|
||||
?: InstanceEntity(
|
||||
accountManager.activeAccount?.domain!!,
|
||||
emojis,
|
||||
either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars,
|
||||
either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions,
|
||||
either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars,
|
||||
either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration,
|
||||
either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration,
|
||||
either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
|
||||
either.asRight().version
|
||||
)
|
||||
viewModelScope.launch {
|
||||
emojisMutable.postValue(instanceInfoRepo.getEmojis())
|
||||
}
|
||||
.doOnSuccess {
|
||||
appDatabase.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.subscribe(
|
||||
{
|
||||
emojisMutable.postValue(it.emojiList.orEmpty())
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to get custom emojis.", it)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
announcementsMutable.postValue(Loading())
|
||||
mastodonApi.listAnnouncements()
|
||||
.subscribe(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.subscribe(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(TAG, "Failed to mark announcement as read.", throwable)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
viewModelScope.launch {
|
||||
announcementsMutable.postValue(Loading())
|
||||
mastodonApi.listAnnouncements()
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(Success(it))
|
||||
it.filter { announcement -> !announcement.read }
|
||||
.forEach { announcement ->
|
||||
mastodonApi.dismissAnnouncement(announcement.id)
|
||||
.fold(
|
||||
{
|
||||
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
|
||||
},
|
||||
{ throwable ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to mark announcement as read.",
|
||||
throwable
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
announcementsMutable.postValue(Error(cause = it))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(announcementId: String, name: String) {
|
||||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.subscribe(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
|
||||
announcement.reactions.map { reaction ->
|
||||
viewModelScope.launch {
|
||||
mastodonApi.addAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
|
||||
announcement.reactions.map { reaction ->
|
||||
if (reaction.name == name) {
|
||||
reaction.copy(
|
||||
count = reaction.count + 1,
|
||||
me = true
|
||||
)
|
||||
} else {
|
||||
reaction
|
||||
}
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }
|
||||
!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
true,
|
||||
url,
|
||||
staticUrl
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to add reaction to the announcement.", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReaction(announcementId: String, name: String) {
|
||||
viewModelScope.launch {
|
||||
mastodonApi.removeAnnouncementReaction(announcementId, name)
|
||||
.fold(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = announcement.reactions.mapNotNull { reaction ->
|
||||
if (reaction.name == name) {
|
||||
reaction.copy(
|
||||
count = reaction.count + 1,
|
||||
me = true
|
||||
)
|
||||
if (reaction.count > 1) {
|
||||
reaction.copy(
|
||||
count = reaction.count - 1,
|
||||
me = false
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
reaction
|
||||
}
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
*announcement.reactions.toTypedArray(),
|
||||
emojis.value!!.find { emoji -> emoji.shortcode == name }
|
||||
!!.run {
|
||||
Announcement.Reaction(
|
||||
name,
|
||||
1,
|
||||
true,
|
||||
url,
|
||||
staticUrl
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to add reaction to the announcement.", it)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun removeReaction(announcementId: String, name: String) {
|
||||
mastodonApi.removeAnnouncementReaction(announcementId, name)
|
||||
.subscribe(
|
||||
{
|
||||
announcementsMutable.postValue(
|
||||
Success(
|
||||
announcements.value!!.data!!.map { announcement ->
|
||||
if (announcement.id == announcementId) {
|
||||
announcement.copy(
|
||||
reactions = announcement.reactions.mapNotNull { reaction ->
|
||||
if (reaction.name == name) {
|
||||
if (reaction.count > 1) {
|
||||
reaction.copy(
|
||||
count = reaction.count - 1,
|
||||
me = false
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
reaction
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
announcement
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
},
|
||||
{
|
||||
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -53,6 +53,7 @@ import androidx.core.view.OnReceiveContentListener
|
|||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
|
@ -69,6 +70,7 @@ import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
|
|||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
import com.keylesspalace.tusky.databinding.ActivityComposeBinding
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.DraftAttachment
|
||||
|
@ -97,6 +99,7 @@ import com.mikepenz.iconics.IconicsDrawable
|
|||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import com.mikepenz.iconics.utils.colorInt
|
||||
import com.mikepenz.iconics.utils.sizeDp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
@ -129,8 +132,8 @@ class ComposeActivity :
|
|||
private var photoUploadUri: Uri? = null
|
||||
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH
|
||||
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT
|
||||
var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL
|
||||
|
||||
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
|
||||
|
||||
|
@ -382,11 +385,10 @@ class ComposeActivity :
|
|||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
viewModel.instanceInfo.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
|
||||
updateVisibleCharactersLeft()
|
||||
binding.composeScheduleButton.visible(instanceData.supportsScheduled)
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
|
||||
|
@ -740,7 +742,7 @@ class ComposeActivity :
|
|||
|
||||
private fun openPollDialog() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceParams.value!!
|
||||
val instanceParams = viewModel.instanceInfo.value!!
|
||||
showAddPollDialog(
|
||||
this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration,
|
||||
|
@ -947,25 +949,15 @@ class ComposeActivity :
|
|||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri).observe { exceptionOrItem ->
|
||||
exceptionOrItem.asLeftOrNull()?.let {
|
||||
val errorId = when (it) {
|
||||
is VideoSizeException -> {
|
||||
R.string.error_video_upload_size
|
||||
}
|
||||
is AudioSizeException -> {
|
||||
R.string.error_audio_upload_size
|
||||
}
|
||||
is VideoOrImageException -> {
|
||||
R.string.error_media_upload_image_or_video
|
||||
}
|
||||
else -> {
|
||||
R.string.error_media_upload_opening
|
||||
}
|
||||
}
|
||||
displayTransientError(errorId)
|
||||
lifecycleScope.launch {
|
||||
viewModel.pickMedia(uri).onFailure { throwable ->
|
||||
val errorId = when (throwable) {
|
||||
is VideoSizeException -> R.string.error_video_upload_size
|
||||
is AudioSizeException -> R.string.error_audio_upload_size
|
||||
is VideoOrImageException -> R.string.error_media_upload_image_or_video
|
||||
else -> R.string.error_media_upload_opening
|
||||
}
|
||||
displayTransientError(errorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,14 +20,14 @@ import android.util.Log
|
|||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||
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.Attachment
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
|
@ -35,9 +35,6 @@ import com.keylesspalace.tusky.entity.Status
|
|||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.StatusToSend
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.VersionUtils
|
||||
import com.keylesspalace.tusky.util.combineLiveData
|
||||
import com.keylesspalace.tusky.util.filter
|
||||
import com.keylesspalace.tusky.util.map
|
||||
|
@ -45,10 +42,12 @@ import com.keylesspalace.tusky.util.randomAlphanumericString
|
|||
import com.keylesspalace.tusky.util.toLiveData
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -58,8 +57,8 @@ class ComposeViewModel @Inject constructor(
|
|||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val draftHelper: DraftHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
private val instanceInfoRepo: InstanceInfoRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
|
@ -76,19 +75,8 @@ class ComposeViewModel @Inject constructor(
|
|||
private var contentWarningStateChanged: Boolean = false
|
||||
private var modifiedInitialState: Boolean = false
|
||||
|
||||
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
|
||||
val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
|
||||
|
||||
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,
|
||||
pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||
pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
val markMediaAsSensitive =
|
||||
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
|
||||
|
@ -104,74 +92,41 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
val domain = accountManager.activeAccount?.domain!!
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
private val mediaToJob = mutableMapOf<Long, Job>()
|
||||
|
||||
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
|
||||
|
||||
fun loadInstanceDataFromNetwork(loadActually: Boolean) {
|
||||
when (loadActually) {
|
||||
true -> Single.zip(
|
||||
api.getCustomEmojis(), rxSingle { api.getInstance().getOrThrow() }
|
||||
) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
|
||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
|
||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
|
||||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
|
||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
|
||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
|
||||
version = instance.version
|
||||
)
|
||||
}
|
||||
false -> Single.error(Exception("skipped network access"))
|
||||
viewModelScope.launch {
|
||||
emoji.postValue(when (loadActually) {
|
||||
true -> instanceInfoRepo.getEmojis()
|
||||
false -> instanceInfoRepo.getCachedEmojis()
|
||||
})
|
||||
}
|
||||
viewModelScope.launch {
|
||||
instanceInfo.postValue(when (loadActually) {
|
||||
true -> instanceInfoRepo.getInstanceInfo()
|
||||
false -> instanceInfoRepo.getCachedInstanceInfo()
|
||||
})
|
||||
}
|
||||
.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()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri, description: String? = null): 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>>()
|
||||
mediaUploader.prepareMedia(uri)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size, description)
|
||||
}
|
||||
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri)
|
||||
val mediaItems = media.value!!
|
||||
if (type != QueuedMedia.Type.IMAGE &&
|
||||
mediaItems.isNotEmpty() &&
|
||||
mediaItems[0].type == QueuedMedia.Type.IMAGE
|
||||
) {
|
||||
Result.failure(VideoOrImageException())
|
||||
} else {
|
||||
val queuedMedia = addMediaToQueue(type, uri, size, description)
|
||||
Result.success(queuedMedia)
|
||||
}
|
||||
.subscribe(
|
||||
{ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
},
|
||||
{ error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
return liveData
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(
|
||||
|
@ -187,13 +142,17 @@ class ComposeViewModel @Inject constructor(
|
|||
mediaSize = mediaSize,
|
||||
description = description
|
||||
)
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.subscribe(
|
||||
{ event ->
|
||||
media.postValue(media.value!! + mediaItem)
|
||||
mediaToJob[mediaItem.localId] = viewModelScope.launch {
|
||||
mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.catch { error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
}
|
||||
.collect { event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
?: return@collect
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
|
@ -211,12 +170,8 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{ error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
|
@ -226,7 +181,7 @@ class ComposeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
mediaToJob[item.localId]?.cancel()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
|
@ -307,13 +262,15 @@ class ComposeViewModel @Inject constructor(
|
|||
val sendObservable = media
|
||||
.filter { items -> items.all { it.uploadPercent == -1 } }
|
||||
.map {
|
||||
val mediaIds = ArrayList<String>()
|
||||
val mediaUris = ArrayList<Uri>()
|
||||
val mediaDescriptions = ArrayList<String>()
|
||||
val mediaIds: MutableList<String> = mutableListOf()
|
||||
val mediaUris: MutableList<Uri> = mutableListOf()
|
||||
val mediaDescriptions: MutableList<String> = mutableListOf()
|
||||
val mediaProcessed: MutableList<Boolean> = mutableListOf()
|
||||
for (item in media.value!!) {
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
mediaProcessed.add(false)
|
||||
}
|
||||
|
||||
val tootToSend = StatusToSend(
|
||||
|
@ -333,7 +290,8 @@ class ComposeViewModel @Inject constructor(
|
|||
accountId = accountManager.activeAccount!!.id,
|
||||
draftId = draftId,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0
|
||||
retries = 0,
|
||||
mediaProcessed = mediaProcessed
|
||||
)
|
||||
|
||||
serviceClient.sendToot(tootToSend)
|
||||
|
@ -342,35 +300,24 @@ class ComposeViewModel @Inject constructor(
|
|||
return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
|
||||
suspend fun updateDescription(localId: Long, description: String): 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
|
||||
val updatedItem = newList.find { it.localId == localId }
|
||||
if (updatedItem?.id != null) {
|
||||
return api.updateMedia(updatedItem.id, description)
|
||||
.fold({
|
||||
true
|
||||
}, { throwable ->
|
||||
Log.w(TAG, "failed to update media", throwable)
|
||||
false
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
|
@ -456,7 +403,11 @@ class ComposeViewModel @Inject constructor(
|
|||
val draftAttachments = composeOptions?.draftAttachments
|
||||
if (draftAttachments != null) {
|
||||
// when coming from DraftActivity
|
||||
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
|
||||
draftAttachments.forEach { attachment ->
|
||||
viewModelScope.launch {
|
||||
pickMedia(attachment.uri, attachment.description)
|
||||
}
|
||||
}
|
||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft or ScheduledTootActivity
|
||||
val mediaType = when (a.type) {
|
||||
|
@ -510,13 +461,6 @@ class ComposeViewModel @Inject constructor(
|
|||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
@ -524,29 +468,6 @@ class ComposeViewModel @Inject constructor(
|
|||
|
||||
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 = 50
|
||||
private const val DEFAULT_MIN_POLL_DURATION = 300
|
||||
private const val DEFAULT_MAX_POLL_DURATION = 604800
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
const val DEFAULT_MAXIMUM_URL_LENGTH = 23
|
||||
|
||||
val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "itabashi.0j0.jp", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com",
|
||||
"comm.cx", "fedibird.com", "qoto.org", "kurage.cc", "m.eula.dev", "otogamer.me", "sgp.hostdon.ne.jp",
|
||||
"pomdon.work", "obapom.work")
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val pollMinDuration: Int,
|
||||
val pollMaxDuration: Int,
|
||||
val charactersReservedPerUrl: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Thrown when trying to add an image when video is already present or the other way around
|
||||
*/
|
||||
|
|
|
@ -1,154 +0,0 @@
|
|||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import com.keylesspalace.tusky.util.IOUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation;
|
||||
import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap;
|
||||
|
||||
/**
|
||||
* Reduces the file size of images to fit under a given limit by resizing them, maintaining both
|
||||
* aspect ratio and orientation.
|
||||
*/
|
||||
public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
||||
private int sizeLimit;
|
||||
private ContentResolver contentResolver;
|
||||
private Listener listener;
|
||||
private File tempFile;
|
||||
|
||||
/**
|
||||
* @param sizeLimit the maximum number of bytes each image can take
|
||||
* @param contentResolver to resolve the specified images' URIs
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @param listener to whom the results are given
|
||||
*/
|
||||
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
|
||||
this.sizeLimit = sizeLimit;
|
||||
this.contentResolver = contentResolver;
|
||||
this.tempFile = tempFile;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Uri... uris) {
|
||||
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile);
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver,
|
||||
File tempFile) {
|
||||
for (Uri uri : uris) {
|
||||
InputStream inputStream;
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
// Initially, just get the image dimensions.
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
BitmapFactory.decodeStream(inputStream, null, options);
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
// Get EXIF data, for orientation info.
|
||||
int orientation = getImageOrientation(uri, contentResolver);
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
int scaledImageSize = 1024;
|
||||
do {
|
||||
OutputStream stream;
|
||||
try {
|
||||
stream = new FileOutputStream(tempFile);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
inputStream = contentResolver.openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize);
|
||||
options.inJustDecodeBounds = false;
|
||||
Bitmap scaledBitmap;
|
||||
try {
|
||||
scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options);
|
||||
} catch (OutOfMemoryError error) {
|
||||
return false;
|
||||
} finally {
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
}
|
||||
if (scaledBitmap == null) {
|
||||
return false;
|
||||
}
|
||||
Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation);
|
||||
if (reorientedBitmap == null) {
|
||||
scaledBitmap.recycle();
|
||||
return false;
|
||||
}
|
||||
Bitmap.CompressFormat format;
|
||||
/* It's not likely the user will give transparent images over the upload limit, but
|
||||
* if they do, make sure the transparency is retained. */
|
||||
if (!reorientedBitmap.hasAlpha()) {
|
||||
format = Bitmap.CompressFormat.JPEG;
|
||||
} else {
|
||||
format = Bitmap.CompressFormat.PNG;
|
||||
}
|
||||
reorientedBitmap.compress(format, 85, stream);
|
||||
reorientedBitmap.recycle();
|
||||
scaledImageSize /= 2;
|
||||
} while (tempFile.length() > sizeLimit);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to communicate the results of the task.
|
||||
*/
|
||||
public interface Listener {
|
||||
void onSuccess(File file);
|
||||
|
||||
void onFailure();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/* Copyright 2022 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.compose
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.CompressFormat
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import com.keylesspalace.tusky.util.IOUtils
|
||||
import com.keylesspalace.tusky.util.calculateInSampleSize
|
||||
import com.keylesspalace.tusky.util.getImageOrientation
|
||||
import com.keylesspalace.tusky.util.reorientBitmap
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* @param uri the uri pointing to the input file
|
||||
* @param sizeLimit the maximum number of bytes the output image is allowed to have
|
||||
* @param contentResolver to resolve the specified input uri
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @return true when the image was successfully resized, false otherwise
|
||||
*/
|
||||
fun downsizeImage(
|
||||
uri: Uri,
|
||||
sizeLimit: Int,
|
||||
contentResolver: ContentResolver,
|
||||
tempFile: File
|
||||
): Boolean {
|
||||
|
||||
val decodeBoundsInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
// Initially, just get the image dimensions.
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(decodeBoundsInputStream, null, options)
|
||||
IOUtils.closeQuietly(decodeBoundsInputStream)
|
||||
// Get EXIF data, for orientation info.
|
||||
val orientation = getImageOrientation(uri, contentResolver)
|
||||
/* Unfortunately, there isn't a determined worst case compression ratio for image
|
||||
* formats. So, the only way to tell if they're too big is to compress them and
|
||||
* test, and keep trying at smaller sizes. The initial estimate should be good for
|
||||
* many cases, so it should only iterate once, but the loop is used to be absolutely
|
||||
* sure it gets downsized to below the limit. */
|
||||
var scaledImageSize = 1024
|
||||
do {
|
||||
val outputStream = try {
|
||||
FileOutputStream(tempFile)
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
val decodeBitmapInputStream = try {
|
||||
contentResolver.openInputStream(uri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
return false
|
||||
}
|
||||
options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize)
|
||||
options.inJustDecodeBounds = false
|
||||
val scaledBitmap: Bitmap = try {
|
||||
BitmapFactory.decodeStream(decodeBitmapInputStream, null, options)
|
||||
} catch (error: OutOfMemoryError) {
|
||||
return false
|
||||
} finally {
|
||||
IOUtils.closeQuietly(decodeBitmapInputStream)
|
||||
} ?: return false
|
||||
|
||||
val reorientedBitmap = reorientBitmap(scaledBitmap, orientation)
|
||||
if (reorientedBitmap == null) {
|
||||
scaledBitmap.recycle()
|
||||
return false
|
||||
}
|
||||
/* Retain transparency if there is any by encoding as png */
|
||||
val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) {
|
||||
CompressFormat.JPEG
|
||||
} else {
|
||||
CompressFormat.PNG
|
||||
}
|
||||
reorientedBitmap.compress(format, 85, outputStream)
|
||||
reorientedBitmap.recycle()
|
||||
scaledImageSize /= 2
|
||||
} while (tempFile.length() > sizeLimit)
|
||||
|
||||
return true
|
||||
}
|
|
@ -32,9 +32,14 @@ import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
|||
import com.keylesspalace.tusky.util.getImageSquarePixels
|
||||
import com.keylesspalace.tusky.util.getMediaSize
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.File
|
||||
|
@ -72,61 +77,40 @@ class MediaUploader @Inject constructor(
|
|||
private val context: Context,
|
||||
private val mastodonApi: MastodonApi
|
||||
) {
|
||||
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable
|
||||
.fromCallable {
|
||||
if (shouldResizeMedia(media)) {
|
||||
downsize(media)
|
||||
} else media
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return flow {
|
||||
if (shouldResizeMedia(media)) {
|
||||
emit(downsize(media))
|
||||
} else {
|
||||
emit(media)
|
||||
}
|
||||
.switchMap { upload(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
.flatMapLatest { upload(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
|
||||
return Single.fromCallable {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
var mimeType: String? = null
|
||||
fun prepareMedia(inUri: Uri): PreparedMedia {
|
||||
var mediaSize = MEDIA_SIZE_UNKNOWN
|
||||
var uri = inUri
|
||||
val mimeType: String?
|
||||
|
||||
try {
|
||||
when (inUri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
try {
|
||||
when (inUri.scheme) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
|
||||
mimeType = contentResolver.getType(uri)
|
||||
mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
val path = uri.path
|
||||
if (path == null) {
|
||||
Log.w(TAG, "empty uri path $uri")
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
val inputFile = File(path)
|
||||
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
||||
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
|
||||
val input = FileInputStream(inputFile)
|
||||
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
|
@ -137,53 +121,74 @@ class MediaUploader @Inject constructor(
|
|||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown uri scheme $uri")
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
val path = uri.path
|
||||
if (path == null) {
|
||||
Log.w(TAG, "empty uri path $uri")
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
Log.w(TAG, "Could not determine file size of upload")
|
||||
throw MediaTypeException()
|
||||
}
|
||||
val inputFile = File(path)
|
||||
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
||||
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
||||
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
|
||||
val input = FileInputStream(inputFile)
|
||||
|
||||
if (mimeType != null) {
|
||||
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
|
||||
when (topLevelType) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
|
||||
throw AudioSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file
|
||||
)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not determine mime type of upload")
|
||||
throw MediaTypeException()
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown uri scheme $uri")
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
Log.w(TAG, "Could not determine file size of upload")
|
||||
throw MediaTypeException()
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
"audio" -> {
|
||||
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
|
||||
throw AudioSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not determine mime type of upload")
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable.create { emitter ->
|
||||
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
||||
return callbackFlow {
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
|
@ -200,11 +205,11 @@ class MediaUploader @Inject constructor(
|
|||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(
|
||||
stream, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()
|
||||
stream!!, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()!!
|
||||
) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
emitter.onNext(UploadEvent.ProgressEvent(percentage))
|
||||
trySend(UploadEvent.ProgressEvent(percentage))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
@ -217,34 +222,20 @@ class MediaUploader @Inject constructor(
|
|||
null
|
||||
}
|
||||
|
||||
val uploadDisposable = mastodonApi.uploadMedia(body, description)
|
||||
.subscribe(
|
||||
{ result ->
|
||||
if (media.uri.scheme == "file") {
|
||||
media.uri.path?.let {
|
||||
File(it).delete()
|
||||
}
|
||||
}
|
||||
|
||||
emitter.onNext(UploadEvent.FinishedEvent(result.id))
|
||||
emitter.onComplete()
|
||||
},
|
||||
{ e ->
|
||||
emitter.onError(e)
|
||||
}
|
||||
)
|
||||
|
||||
// Cancel the request when our observable is cancelled
|
||||
emitter.setDisposable(uploadDisposable)
|
||||
val result = mastodonApi.uploadMedia(body, description).getOrThrow()
|
||||
if (media.uri.scheme == "file") {
|
||||
media.uri.path?.let {
|
||||
File(it).delete()
|
||||
}
|
||||
}
|
||||
send(UploadEvent.FinishedEvent(result.id))
|
||||
awaitClose()
|
||||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
DownsizeImageTask.resize(
|
||||
arrayOf(media.uri),
|
||||
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file
|
||||
)
|
||||
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ import android.widget.LinearLayout
|
|||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
|
@ -35,7 +35,7 @@ import com.bumptech.glide.request.target.CustomTarget
|
|||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.github.chrisbanes.photoview.PhotoView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.withLifecycleContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
||||
|
@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500
|
|||
fun <T> T.makeCaptionDialog(
|
||||
existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
onUpdateDescription: (String) -> LiveData<Boolean>
|
||||
onUpdateDescription: suspend (String) -> Boolean
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
val dialogLayout = LinearLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
|
@ -77,12 +77,11 @@ fun <T> T.makeCaptionDialog(
|
|||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
|
||||
val okListener = { dialog: DialogInterface, _: Int ->
|
||||
onUpdateDescription(input.text.toString())
|
||||
withLifecycleContext {
|
||||
onUpdateDescription(input.text.toString())
|
||||
.observe { success -> if (!success) showFailedCaptionMessage() }
|
||||
lifecycleScope.launch {
|
||||
if (!onUpdateDescription(input.text.toString())) {
|
||||
showFailedCaptionMessage()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ import androidx.core.view.OnReceiveContentListener
|
|||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.emoji.widget.EmojiEditTextHelper
|
||||
import androidx.emoji2.viewsintegration.EmojiEditTextHelper
|
||||
|
||||
class EditTextTyped @JvmOverloads constructor(
|
||||
context: Context,
|
||||
|
|
|
@ -32,7 +32,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
|
|
|
@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity
|
|||
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding
|
||||
import com.keylesspalace.tusky.db.DraftEntity
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.util.parseAsMastodonHtml
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
@ -100,7 +101,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
|
|||
content = draft.content,
|
||||
contentWarning = draft.contentWarning,
|
||||
inReplyToId = draft.inReplyToId,
|
||||
replyingStatusContent = status.content.toString(),
|
||||
replyingStatusContent = status.content.parseAsMastodonHtml().toString(),
|
||||
replyingStatusAuthor = status.account.localUsername,
|
||||
draftAttachments = draft.attachments,
|
||||
poll = draft.poll,
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/* Copyright 2022 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.instanceinfo
|
||||
|
||||
data class InstanceInfo(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val pollMinDuration: Int,
|
||||
val pollMaxDuration: Int,
|
||||
val charactersReservedPerUrl: Int
|
||||
)
|
|
@ -0,0 +1,130 @@
|
|||
/* Copyright 2022 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.instanceinfo
|
||||
|
||||
import android.util.Log
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.EmojisEntity
|
||||
import com.keylesspalace.tusky.db.InstanceInfoEntity
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class InstanceInfoRepository @Inject constructor(
|
||||
private val api: MastodonApi,
|
||||
db: AppDatabase,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
|
||||
private val dao = db.instanceDao()
|
||||
private val instanceName = accountManager.activeAccount!!.domain
|
||||
|
||||
/**
|
||||
* Returns the custom emojis of the instance.
|
||||
* Will always try to fetch them from the api, falls back to cached Emojis in case it is not available.
|
||||
* Never throws, returns empty list in case of error.
|
||||
*/
|
||||
suspend fun getEmojis(): List<Emoji> = withContext(Dispatchers.IO) {
|
||||
api.getCustomEmojis()
|
||||
.onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) }
|
||||
.getOrElse { throwable ->
|
||||
Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable)
|
||||
getCachedEmojis()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCachedEmojis(): List<Emoji> =
|
||||
dao.getEmojiInfo(instanceName)?.emojiList.orEmpty()
|
||||
|
||||
/**
|
||||
* Returns information about the instance.
|
||||
* Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available.
|
||||
* Never throws, returns defaults of vanilla Mastodon in case of error.
|
||||
*/
|
||||
suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) {
|
||||
api.getInstance()
|
||||
.fold(
|
||||
{ instance ->
|
||||
val instanceEntity = InstanceInfoEntity(
|
||||
instance = instanceName,
|
||||
maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars,
|
||||
maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions,
|
||||
maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars,
|
||||
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
|
||||
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
|
||||
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
|
||||
version = instance.version
|
||||
)
|
||||
dao.insertOrReplace(instanceEntity)
|
||||
instanceEntity
|
||||
},
|
||||
{ throwable ->
|
||||
Log.w(TAG, "failed to instance, falling back to cache and default values", throwable)
|
||||
getCachedInstanceInfoEntity()
|
||||
}
|
||||
).toInstanceInfo()
|
||||
}
|
||||
|
||||
private suspend fun getCachedInstanceInfoEntity(): InstanceInfoEntity? =
|
||||
dao.getInstanceInfo(instanceName)
|
||||
|
||||
suspend fun getCachedInstanceInfo(): InstanceInfo =
|
||||
getCachedInstanceInfoEntity().toInstanceInfo()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstanceInfoRepo"
|
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 50
|
||||
private const val DEFAULT_MIN_POLL_DURATION = 300
|
||||
private const val DEFAULT_MAX_POLL_DURATION = 604800
|
||||
|
||||
@JvmField
|
||||
val CAN_USE_QUOTE_ID = arrayOf(
|
||||
"odakyu.app",
|
||||
"itabashi.0j0.jp",
|
||||
"biwakodon.com",
|
||||
"dtp-mstdn.jp",
|
||||
"nitiasa.com",
|
||||
"comm.cx",
|
||||
"fedibird.com",
|
||||
"qoto.org",
|
||||
"kurage.cc",
|
||||
"m.eula.dev",
|
||||
"otogamer.me",
|
||||
"sgp.hostdon.ne.jp",
|
||||
"pomdon.work",
|
||||
"obapom.work",
|
||||
)
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
|
||||
|
||||
fun InstanceInfoEntity?.toInstanceInfo(): InstanceInfo =
|
||||
InstanceInfo(
|
||||
maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
|
||||
pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
|
||||
charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL
|
||||
)
|
||||
}
|
||||
}
|
|
@ -19,8 +19,10 @@ import androidx.activity.result.contract.ActivityResultContract
|
|||
import androidx.core.net.toUri
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.BuildConfig
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.databinding.LoginWebviewBinding
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
|
@ -87,7 +89,9 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
|
||||
setSupportActionBar(binding.loginToolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(false)
|
||||
supportActionBar?.setDisplayShowTitleEnabled(true)
|
||||
|
||||
setTitle(R.string.title_login)
|
||||
|
||||
val webView = binding.loginWebView
|
||||
webView.settings.allowContentAccess = false
|
||||
|
@ -103,13 +107,17 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
val oauthUrl = data.oauthRedirectUrl
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
binding.loginProgress.hide()
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
error: WebResourceError
|
||||
) {
|
||||
Log.d("LoginWeb", "Failed to load ${data.url}: $error")
|
||||
finish()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
|
@ -165,10 +173,14 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
|
|||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun requiresLogin() = false
|
||||
|
||||
private fun sendResult(result: LoginResult) {
|
||||
setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result))
|
||||
finish()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,6 +118,7 @@ public class NotificationHelper {
|
|||
public static final String CHANNEL_POLL = "CHANNEL_POLL";
|
||||
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
|
||||
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
|
||||
public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES";
|
||||
|
||||
/**
|
||||
* WorkManager Tag
|
||||
|
@ -395,6 +396,7 @@ public class NotificationHelper {
|
|||
CHANNEL_POLL + account.getIdentifier(),
|
||||
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
|
||||
CHANNEL_SIGN_UP + account.getIdentifier(),
|
||||
CHANNEL_UPDATES + account.getIdentifier(),
|
||||
};
|
||||
int[] channelNames = {
|
||||
R.string.notification_mention_name,
|
||||
|
@ -405,6 +407,7 @@ public class NotificationHelper {
|
|||
R.string.notification_poll_name,
|
||||
R.string.notification_subscription_name,
|
||||
R.string.notification_sign_up_name,
|
||||
R.string.notification_update_name,
|
||||
};
|
||||
int[] channelDescriptions = {
|
||||
R.string.notification_mention_descriptions,
|
||||
|
@ -415,6 +418,7 @@ public class NotificationHelper {
|
|||
R.string.notification_poll_description,
|
||||
R.string.notification_subscription_description,
|
||||
R.string.notification_sign_up_description,
|
||||
R.string.notification_update_description,
|
||||
};
|
||||
|
||||
List<NotificationChannel> channels = new ArrayList<>(6);
|
||||
|
@ -567,6 +571,8 @@ public class NotificationHelper {
|
|||
return account.getNotificationsPolls();
|
||||
case SIGN_UP:
|
||||
return account.getNotificationsSignUps();
|
||||
case UPDATE:
|
||||
return account.getNotificationsUpdates();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
@ -674,6 +680,8 @@ public class NotificationHelper {
|
|||
}
|
||||
case SIGN_UP:
|
||||
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
|
||||
case UPDATE:
|
||||
return String.format(context.getString(R.string.notification_update_format), accountName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,240 +0,0 @@
|
|||
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.RadioButton
|
||||
import android.widget.Toast
|
||||
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.components.notifications.NotificationHelper
|
||||
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
|
||||
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.BLOBMOJI
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.NOTOEMOJI
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT
|
||||
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.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 binding = DialogEmojicompatBinding.inflate(LayoutInflater.from(context))
|
||||
|
||||
setupItem(BLOBMOJI, binding.itemBlobmoji)
|
||||
setupItem(TWEMOJI, binding.itemTwemoji)
|
||||
setupItem(NOTOEMOJI, binding.itemNotoemoji)
|
||||
setupItem(SYSTEM_DEFAULT, binding.itemNomoji)
|
||||
|
||||
AlertDialog.Builder(context)
|
||||
.setView(binding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
|
||||
// Initialize all the views
|
||||
binding.emojiName.text = font.getDisplay(context)
|
||||
binding.emojiCaption.setText(font.caption)
|
||||
binding.emojiThumbnail.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(binding.emojiRadioButton)
|
||||
updateItem(font, binding)
|
||||
|
||||
// Set actions
|
||||
binding.emojiDownload.setOnClickListener { startDownload(font, binding) }
|
||||
binding.emojiDownloadCancel.setOnClickListener { cancelDownload(font, binding) }
|
||||
binding.emojiRadioButton.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) }
|
||||
binding.root.setOnClickListener {
|
||||
select(font, binding.emojiRadioButton)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
|
||||
// Switch to downloading style
|
||||
binding.emojiDownload.hide()
|
||||
binding.emojiCaption.visibility = View.INVISIBLE
|
||||
binding.emojiProgress.show()
|
||||
binding.emojiProgress.progress = 0
|
||||
binding.emojiDownloadCancel.show()
|
||||
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) {
|
||||
binding.emojiProgress.isIndeterminate = false
|
||||
val max = binding.emojiProgress.max.toFloat()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
binding.emojiProgress.setProgress((max * progress).toInt(), true)
|
||||
} else {
|
||||
binding.emojiProgress.progress = (max * progress).toInt()
|
||||
}
|
||||
} else {
|
||||
binding.emojiProgress.isIndeterminate = true
|
||||
}
|
||||
},
|
||||
{
|
||||
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
|
||||
updateItem(font, binding)
|
||||
},
|
||||
{
|
||||
finishDownload(font, binding)
|
||||
}
|
||||
).also { downloadDisposables[font.id] = it }
|
||||
}
|
||||
|
||||
private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
|
||||
font.deleteDownloadedFile(context)
|
||||
downloadDisposables[font.id]?.dispose()
|
||||
downloadDisposables[font.id] = null
|
||||
updateItem(font, binding)
|
||||
}
|
||||
|
||||
private fun finishDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
|
||||
select(font, binding.emojiRadioButton)
|
||||
updateItem(font, binding)
|
||||
// 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
|
||||
radioButtons.forEach { radioButton ->
|
||||
radioButton.isChecked = radioButton == radio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a "consistent" state is reached, i.e. it's not downloading the font
|
||||
*
|
||||
* @param font The font to be displayed
|
||||
* @param binding The ItemEmojiPrefBinding to show the item in
|
||||
*/
|
||||
private fun updateItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) {
|
||||
// There's no download going on
|
||||
binding.emojiProgress.hide()
|
||||
binding.emojiDownloadCancel.hide()
|
||||
binding.emojiCaption.show()
|
||||
if (font.isDownloaded(context)) {
|
||||
// Make it selectable
|
||||
binding.emojiDownload.hide()
|
||||
binding.emojiRadioButton.show()
|
||||
binding.root.isClickable = true
|
||||
} else {
|
||||
// Make it downloadable
|
||||
binding.emojiDownload.show()
|
||||
binding.emojiRadioButton.hide()
|
||||
binding.root.isClickable = false
|
||||
}
|
||||
|
||||
// Select it if necessary
|
||||
if (font === selected) {
|
||||
binding.emojiRadioButton.isChecked = true
|
||||
// Update available
|
||||
if (!font.isDownloaded(context)) {
|
||||
currentNeedsUpdate = true
|
||||
}
|
||||
} else {
|
||||
binding.emojiRadioButton.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,
|
||||
NotificationHelper.pendingIntentFlags(false)
|
||||
)
|
||||
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"
|
||||
}
|
||||
}
|
|
@ -133,6 +133,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
setTitle(R.string.pref_title_notification_filter_updates)
|
||||
key = PrefKeys.NOTIFICATION_FILTER_UPDATES
|
||||
isIconSpaceReserved = false
|
||||
isChecked = activeAccount.notificationsUpdates
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
updateAccount { it.notificationsUpdates = newValue as Boolean }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceCategory(R.string.pref_title_notification_alerts) { category ->
|
||||
|
|
|
@ -41,14 +41,11 @@ 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 de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
|
||||
import javax.inject.Inject
|
||||
|
||||
class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var okhttpclient: OkHttpClient
|
||||
|
||||
@Inject
|
||||
lateinit var accountManager: AccountManager
|
||||
|
||||
|
@ -71,11 +68,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
|
||||
}
|
||||
|
||||
emojiPreference(okhttpclient) {
|
||||
setDefaultValue("system_default")
|
||||
setIcon(R.drawable.ic_emoji_24dp)
|
||||
key = PrefKeys.EMOJI
|
||||
setSummary(R.string.system_default)
|
||||
emojiPreference(requireActivity()) {
|
||||
setTitle(R.string.emoji_style)
|
||||
icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied)
|
||||
}
|
||||
|
@ -377,6 +370,12 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||
if (!EmojiPickerPreference.onDisplayPreferenceDialog(this, preference)) {
|
||||
super.onDisplayPreferenceDialog(preference)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance(): PreferencesFragment {
|
||||
return PreferencesFragment()
|
||||
|
|
|
@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.AbsoluteTimeFormatter
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.StatusViewHelper
|
||||
import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER
|
||||
|
@ -51,6 +52,7 @@ class StatusViewHolder(
|
|||
|
||||
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
|
||||
private val statusViewHelper = StatusViewHelper(itemView)
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
private val previewListener = object : StatusViewHelper.MediaPreviewListener {
|
||||
override fun onViewMedia(v: View?, idx: Int) {
|
||||
|
@ -155,7 +157,7 @@ class StatusViewHolder(
|
|||
|
||||
private fun setCreatedAt(createdAt: Date?) {
|
||||
if (statusDisplayOptions.useAbsoluteTime) {
|
||||
binding.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt)
|
||||
binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt)
|
||||
} else {
|
||||
binding.timestampInfo.text = if (createdAt != null) {
|
||||
val then = createdAt.time
|
||||
|
|
|
@ -32,7 +32,7 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.account.AccountActivity
|
||||
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||
import com.keylesspalace.tusky.components.report.Screen
|
||||
import com.keylesspalace.tusky.components.report.adapter.AdapterHandler
|
||||
|
|
|
@ -85,6 +85,10 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
|
|||
return true
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
private fun getPageTitle(position: Int): CharSequence {
|
||||
return when (position) {
|
||||
0 -> getString(R.string.title_posts)
|
||||
|
|
|
@ -20,7 +20,7 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchNotestockPagingSourceFactory
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
|
|
|
@ -111,9 +111,13 @@ abstract class SearchFragment<T : Any> :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id))
|
||||
override fun onViewAccount(id: String) {
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id))
|
||||
}
|
||||
|
||||
override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
override fun onViewTag(tag: String) {
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
|
||||
}
|
||||
|
||||
override fun onViewUrl(url: String, text: String) {
|
||||
bottomSheetActivity?.viewUrl(url, text = text)
|
||||
|
|
|
@ -98,7 +98,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
}
|
||||
|
||||
override fun onReply(position: Int) {
|
||||
searchAdapter.peek(position)?.status?.let { status ->
|
||||
searchAdapter.peek(position)?.let { status ->
|
||||
reply(status)
|
||||
}
|
||||
}
|
||||
|
@ -206,8 +206,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
fun newInstance() = SearchStatusesFragment()
|
||||
}
|
||||
|
||||
private fun reply(status: Status) {
|
||||
val actionableStatus = status.actionableStatus
|
||||
private fun reply(status: StatusViewData.Concrete) {
|
||||
val actionableStatus = status.actionable
|
||||
val mentionedUsernames = actionableStatus.mentions.map { it.username }
|
||||
.toMutableSet()
|
||||
.apply {
|
||||
|
@ -223,10 +223,10 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
|
|||
contentWarning = actionableStatus.spoilerText,
|
||||
mentionedUsernames = mentionedUsernames,
|
||||
replyingStatusAuthor = actionableStatus.account.localUsername,
|
||||
replyingStatusContent = actionableStatus.content.toString()
|
||||
replyingStatusContent = status.content.toString()
|
||||
)
|
||||
)
|
||||
startActivity(intent)
|
||||
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
|
||||
}
|
||||
|
||||
private fun quote(status: Status) {
|
||||
|
|
|
@ -43,7 +43,7 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
|
||||
import com.keylesspalace.tusky.appstore.QuickReplyEvent
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent
|
||||
import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
|
||||
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
|
||||
|
@ -53,7 +53,11 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.fragment.SFragment
|
||||
import com.keylesspalace.tusky.interfaces.*
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.RefreshableFragment
|
||||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.ResettableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.settings.PrefKeys
|
||||
import com.keylesspalace.tusky.util.CardViewMode
|
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate
|
||||
|
@ -177,7 +181,7 @@ class TimelineFragment :
|
|||
setupRecyclerView()
|
||||
|
||||
adapter.addLoadStateListener { loadState ->
|
||||
if (loadState.refresh != LoadState.Loading) {
|
||||
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
|
||||
binding.swipeRefreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ data class AccountEntity(
|
|||
var notificationsPolls: Boolean = true,
|
||||
var notificationsSubscriptions: Boolean = true,
|
||||
var notificationsSignUps: Boolean = true,
|
||||
var notificationsUpdates: Boolean = true,
|
||||
var notificationSound: Boolean = true,
|
||||
var notificationVibration: Boolean = true,
|
||||
var notificationLight: Boolean = true,
|
||||
|
|
|
@ -31,7 +31,7 @@ import java.io.File;
|
|||
*/
|
||||
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
|
||||
TimelineAccountEntity.class, ConversationEntity.class
|
||||
}, version = 33)
|
||||
}, version = 34)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public abstract AccountDao accountDao();
|
||||
|
@ -527,4 +527,11 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
"PRIMARY KEY(`id`, `accountId`))");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_33_34 = new Migration(33, 34) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -19,13 +19,19 @@ import androidx.room.Dao
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
|
||||
@Dao
|
||||
interface InstanceDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(instance: InstanceEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
|
||||
suspend fun insertOrReplace(instance: InstanceInfoEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
|
||||
suspend fun insertOrReplace(emojis: EmojisEntity)
|
||||
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
fun loadMetadataForInstance(instance: String): Single<InstanceEntity>
|
||||
suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
|
||||
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
suspend fun getEmojiInfo(instance: String): EmojisEntity?
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import com.keylesspalace.tusky.entity.Emoji
|
|||
@Entity
|
||||
@TypeConverters(Converters::class)
|
||||
data class InstanceEntity(
|
||||
@field:PrimaryKey var instance: String,
|
||||
@PrimaryKey val instance: String,
|
||||
val emojiList: List<Emoji>?,
|
||||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
|
@ -33,3 +33,20 @@ data class InstanceEntity(
|
|||
val charactersReservedPerUrl: Int?,
|
||||
val version: String?
|
||||
)
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
data class EmojisEntity(
|
||||
@PrimaryKey val instance: String,
|
||||
val emojiList: List<Emoji>?
|
||||
)
|
||||
|
||||
data class InstanceInfoEntity(
|
||||
@PrimaryKey val instance: String,
|
||||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
val maxPollOptionLength: Int?,
|
||||
val minPollDuration: Int?,
|
||||
val maxPollDuration: Int?,
|
||||
val charactersReservedPerUrl: Int?,
|
||||
val version: String?
|
||||
)
|
||||
|
|
|
@ -69,7 +69,7 @@ class AppModule {
|
|||
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
|
||||
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
|
||||
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
|
||||
AppDatabase.MIGRATION_32_33
|
||||
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class IdentityProof(
|
||||
val provider: String,
|
||||
@SerializedName("provider_username") val username: String,
|
||||
@SerializedName("profile_url") val profileUrl: String
|
||||
)
|
|
@ -39,6 +39,7 @@ data class Notification(
|
|||
POLL("poll"),
|
||||
STATUS("status"),
|
||||
SIGN_UP("admin.sign_up"),
|
||||
UPDATE("update"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
@ -51,7 +52,7 @@ data class Notification(
|
|||
}
|
||||
return UNKNOWN
|
||||
}
|
||||
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP)
|
||||
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID;
|
||||
import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
|
||||
import static autodispose2.AutoDispose.autoDisposable;
|
||||
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
@ -66,7 +67,6 @@ import com.keylesspalace.tusky.appstore.PinEvent;
|
|||
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent;
|
||||
import com.keylesspalace.tusky.appstore.QuickReplyEvent;
|
||||
import com.keylesspalace.tusky.appstore.ReblogEvent;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModelKt;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
|
@ -264,7 +264,7 @@ public class NotificationsFragment extends SFragment implements
|
|||
preferences.getBoolean("confirmFavourites", false),
|
||||
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
Arrays.asList(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain())
|
||||
Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain())
|
||||
);
|
||||
|
||||
adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(),
|
||||
|
@ -718,6 +718,8 @@ public class NotificationsFragment extends SFragment implements
|
|||
return getString(R.string.notification_subscription_name);
|
||||
case SIGN_UP:
|
||||
return getString(R.string.notification_sign_up_name);
|
||||
case UPDATE:
|
||||
return getString(R.string.notification_update_name);
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
|
||||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.DownloadManager;
|
||||
import android.content.ClipData;
|
||||
|
@ -150,7 +152,7 @@ public abstract class SFragment extends Fragment implements Injectable {
|
|||
composeOptions.setContentWarning(contentWarning);
|
||||
composeOptions.setMentionedUsernames(mentionedUsernames);
|
||||
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
|
||||
composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString());
|
||||
composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString());
|
||||
|
||||
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
|
||||
getActivity().startActivity(intent);
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
|
||||
package com.keylesspalace.tusky.fragment;
|
||||
|
||||
import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
|
@ -147,7 +149,7 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
preferences.getBoolean("confirmFavourites", false),
|
||||
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
Arrays.asList(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain())
|
||||
Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain())
|
||||
);
|
||||
adapter = new ThreadAdapter(statusDisplayOptions, this);
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import com.keylesspalace.tusky.entity.Conversation
|
|||
import com.keylesspalace.tusky.entity.DeletedStatus
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Filter
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.entity.Instance
|
||||
import com.keylesspalace.tusky.entity.Marker
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
|
@ -77,7 +76,7 @@ interface MastodonApi {
|
|||
fun getLists(): Single<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
fun getCustomEmojis(): Single<List<Emoji>>
|
||||
suspend fun getCustomEmojis(): Result<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
suspend fun getInstance(): Result<Instance>
|
||||
|
@ -145,25 +144,30 @@ interface MastodonApi {
|
|||
|
||||
@Multipart
|
||||
@POST("api/v2/media")
|
||||
fun uploadMedia(
|
||||
suspend fun uploadMedia(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part description: MultipartBody.Part? = null
|
||||
): Single<MediaUploadResult>
|
||||
): Result<MediaUploadResult>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/media/{mediaId}")
|
||||
fun updateMedia(
|
||||
suspend fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Single<Attachment>
|
||||
): Result<Attachment>
|
||||
|
||||
@GET("api/v1/media/{mediaId}")
|
||||
suspend fun getMedia(
|
||||
@Path("mediaId") mediaId: String
|
||||
): Response<MediaUploadResult>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
fun createStatus(
|
||||
suspend fun createStatus(
|
||||
@Header("Authorization") auth: String,
|
||||
@Header(DOMAIN_HEADER) domain: String,
|
||||
@Header("Idempotency-Key") idempotencyKey: String,
|
||||
@Body status: NewStatus
|
||||
): Call<Status>
|
||||
): Result<Status>
|
||||
|
||||
@GET("api/v1/statuses/{id}")
|
||||
fun status(
|
||||
|
@ -367,11 +371,6 @@ interface MastodonApi {
|
|||
@Query("id[]") accountIds: List<String>
|
||||
): Single<List<Relationship>>
|
||||
|
||||
@GET("api/v1/accounts/{id}/identity_proofs")
|
||||
fun identityProofs(
|
||||
@Path("id") accountId: String
|
||||
): Single<List<IdentityProof>>
|
||||
|
||||
@POST("api/v1/pleroma/accounts/{id}/subscribe")
|
||||
fun subscribeAccount(
|
||||
@Path("id") accountId: String
|
||||
|
@ -544,26 +543,26 @@ interface MastodonApi {
|
|||
): Single<Poll>
|
||||
|
||||
@GET("api/v1/announcements")
|
||||
fun listAnnouncements(
|
||||
suspend fun listAnnouncements(
|
||||
@Query("with_dismissed") withDismissed: Boolean = true
|
||||
): Single<List<Announcement>>
|
||||
): Result<List<Announcement>>
|
||||
|
||||
@POST("api/v1/announcements/{id}/dismiss")
|
||||
fun dismissAnnouncement(
|
||||
suspend fun dismissAnnouncement(
|
||||
@Path("id") announcementId: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@PUT("api/v1/announcements/{id}/reactions/{name}")
|
||||
fun addAnnouncementReaction(
|
||||
suspend fun addAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@DELETE("api/v1/announcements/{id}/reactions/{name}")
|
||||
fun removeAnnouncementReaction(
|
||||
suspend fun removeAnnouncementReaction(
|
||||
@Path("id") announcementId: String,
|
||||
@Path("name") name: String
|
||||
): Single<ResponseBody>
|
||||
): Result<ResponseBody>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/reports")
|
||||
|
|
|
@ -101,7 +101,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
accountId = account.id,
|
||||
draftId = -1,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0
|
||||
retries = 0,
|
||||
mediaProcessed = mutableListOf()
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import android.content.Intent
|
|||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
@ -29,13 +30,12 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import dagger.android.AndroidInjection
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import retrofit2.HttpException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
@ -55,7 +55,7 @@ class SendStatusService : Service(), Injectable {
|
|||
private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob)
|
||||
|
||||
private val statusesToSend = ConcurrentHashMap<Int, StatusToSend>()
|
||||
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
|
||||
private val sendJobs = ConcurrentHashMap<Int, Job>()
|
||||
|
||||
private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
|
||||
|
||||
|
@ -64,12 +64,9 @@ class SendStatusService : Service(), Injectable {
|
|||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
|
||||
if (intent.hasExtra(KEY_STATUS)) {
|
||||
val statusToSend = intent.getParcelableExtra<StatusToSend>(KEY_STATUS)
|
||||
?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra")
|
||||
|
@ -129,83 +126,95 @@ class SendStatusService : Service(), Injectable {
|
|||
|
||||
statusToSend.retries++
|
||||
|
||||
val newStatus = NewStatus(
|
||||
statusToSend.text,
|
||||
statusToSend.warningText,
|
||||
statusToSend.inReplyToId,
|
||||
statusToSend.visibility,
|
||||
statusToSend.sensitive,
|
||||
statusToSend.mediaIds,
|
||||
statusToSend.scheduledAt,
|
||||
statusToSend.poll,
|
||||
statusToSend.quoteId,
|
||||
)
|
||||
sendJobs[statusId] = serviceScope.launch {
|
||||
try {
|
||||
var mediaCheckRetries = 0
|
||||
while (statusToSend.mediaProcessed.any { !it }) {
|
||||
delay(1000L * mediaCheckRetries)
|
||||
statusToSend.mediaProcessed.forEachIndexed { index, processed ->
|
||||
if (!processed) {
|
||||
// Mastodon returns 206 if the media was not yet processed
|
||||
statusToSend.mediaProcessed[index] = mastodonApi.getMedia(statusToSend.mediaIds[index]).code() == 200
|
||||
}
|
||||
}
|
||||
mediaCheckRetries ++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed getting media status", e)
|
||||
retrySending(statusId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val sendCall = mastodonApi.createStatus(
|
||||
"Bearer " + account.accessToken,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus
|
||||
)
|
||||
val newStatus = NewStatus(
|
||||
statusToSend.text,
|
||||
statusToSend.warningText,
|
||||
statusToSend.inReplyToId,
|
||||
statusToSend.visibility,
|
||||
statusToSend.sensitive,
|
||||
statusToSend.mediaIds,
|
||||
statusToSend.scheduledAt,
|
||||
statusToSend.poll,
|
||||
statusToSend.quoteId,
|
||||
)
|
||||
|
||||
sendCalls[statusId] = sendCall
|
||||
mastodonApi.createStatus(
|
||||
"Bearer " + account.accessToken,
|
||||
account.domain,
|
||||
statusToSend.idempotencyKey,
|
||||
newStatus
|
||||
).fold({ sentStatus ->
|
||||
statusesToSend.remove(statusId)
|
||||
// If the status was loaded from a draft, delete the draft and associated media files.
|
||||
if (statusToSend.draftId != 0) {
|
||||
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
|
||||
}
|
||||
|
||||
val callback = object : Callback<Status> {
|
||||
override fun onResponse(call: Call<Status>, response: Response<Status>) {
|
||||
serviceScope.launch {
|
||||
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
|
||||
|
||||
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
|
||||
if (scheduled) {
|
||||
eventHub.dispatch(StatusScheduledEvent(sentStatus))
|
||||
} else {
|
||||
eventHub.dispatch(StatusComposedEvent(sentStatus))
|
||||
}
|
||||
|
||||
notificationManager.cancel(statusId)
|
||||
}, { throwable ->
|
||||
Log.w(TAG, "failed sending status", throwable)
|
||||
if (throwable is HttpException) {
|
||||
// the server refused to accept the status, save status & show error message
|
||||
statusesToSend.remove(statusId)
|
||||
saveStatusToDrafts(statusToSend)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
// If the status was loaded from a draft, delete the draft and associated media files.
|
||||
if (statusToSend.draftId != 0) {
|
||||
draftHelper.deleteDraftAndAttachments(statusToSend.draftId)
|
||||
}
|
||||
|
||||
if (scheduled) {
|
||||
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
|
||||
} else {
|
||||
response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
|
||||
}
|
||||
|
||||
notificationManager.cancel(statusId)
|
||||
} else {
|
||||
// the server refused to accept the status, save status & show error message
|
||||
saveStatusToDrafts(statusToSend)
|
||||
|
||||
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentTitle(getString(R.string.send_post_notification_error_title))
|
||||
.setContentText(getString(R.string.send_post_notification_saved_content))
|
||||
.setColor(
|
||||
ContextCompat.getColor(
|
||||
this@SendStatusService,
|
||||
R.color.notification_color
|
||||
)
|
||||
val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notify)
|
||||
.setContentTitle(getString(R.string.send_post_notification_error_title))
|
||||
.setContentText(getString(R.string.send_post_notification_saved_content))
|
||||
.setColor(
|
||||
ContextCompat.getColor(
|
||||
this@SendStatusService,
|
||||
R.color.notification_color
|
||||
)
|
||||
)
|
||||
|
||||
notificationManager.cancel(statusId)
|
||||
notificationManager.notify(errorNotificationId--, builder.build())
|
||||
}
|
||||
stopSelfWhenDone()
|
||||
notificationManager.cancel(statusId)
|
||||
notificationManager.notify(errorNotificationId--, builder.build())
|
||||
} else {
|
||||
// a network problem occurred, let's retry sending the status
|
||||
retrySending(statusId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<Status>, t: Throwable) {
|
||||
serviceScope.launch {
|
||||
var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong())
|
||||
if (backoff > MAX_RETRY_INTERVAL) {
|
||||
backoff = MAX_RETRY_INTERVAL
|
||||
}
|
||||
|
||||
delay(backoff)
|
||||
sendStatus(statusId)
|
||||
}
|
||||
}
|
||||
})
|
||||
stopSelfWhenDone()
|
||||
}
|
||||
}
|
||||
|
||||
sendCall.enqueue(callback)
|
||||
private suspend fun retrySending(statusId: Int) {
|
||||
// when statusToSend == null, sending has been canceled
|
||||
val statusToSend = statusesToSend[statusId] ?: return
|
||||
|
||||
val backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()).coerceAtMost(MAX_RETRY_INTERVAL)
|
||||
|
||||
delay(backoff)
|
||||
sendStatus(statusId)
|
||||
}
|
||||
|
||||
private fun stopSelfWhenDone() {
|
||||
|
@ -219,8 +228,8 @@ class SendStatusService : Service(), Injectable {
|
|||
private fun cancelSending(statusId: Int) = serviceScope.launch {
|
||||
val statusToCancel = statusesToSend.remove(statusId)
|
||||
if (statusToCancel != null) {
|
||||
val sendCall = sendCalls.remove(statusId)
|
||||
sendCall?.cancel()
|
||||
val sendJob = sendJobs.remove(statusId)
|
||||
sendJob?.cancel()
|
||||
|
||||
saveStatusToDrafts(statusToCancel)
|
||||
|
||||
|
@ -264,6 +273,7 @@ class SendStatusService : Service(), Injectable {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SendStatusService"
|
||||
|
||||
private const val KEY_STATUS = "status"
|
||||
private const val KEY_CANCEL = "cancel_id"
|
||||
|
@ -321,5 +331,6 @@ data class StatusToSend(
|
|||
val accountId: Long,
|
||||
val draftId: Int,
|
||||
val idempotencyKey: String,
|
||||
var retries: Int
|
||||
var retries: Int,
|
||||
val mediaProcessed: MutableList<Boolean>
|
||||
) : Parcelable
|
||||
|
|
|
@ -70,6 +70,7 @@ object PrefKeys {
|
|||
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
|
||||
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
|
||||
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
|
||||
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
|
||||
|
||||
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
|
||||
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package com.keylesspalace.tusky.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.result.ActivityResultRegistryOwner
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
|
@ -10,8 +12,7 @@ import androidx.preference.PreferenceCategory
|
|||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreference
|
||||
import com.keylesspalace.tusky.components.preference.EmojiPreference
|
||||
import okhttp3.OkHttpClient
|
||||
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
|
||||
|
||||
class PreferenceParent(
|
||||
val context: Context,
|
||||
|
@ -32,8 +33,9 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit):
|
|||
return pref
|
||||
}
|
||||
|
||||
inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference {
|
||||
val pref = EmojiPreference(context, okHttpClient)
|
||||
inline fun <A> PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference
|
||||
where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner {
|
||||
val pref = EmojiPickerPreference.get(activity)
|
||||
builder(pref)
|
||||
addPref(pref)
|
||||
return pref
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/* Copyright 2022 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) {
|
||||
private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
|
||||
private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
|
||||
private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz }
|
||||
private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz }
|
||||
|
||||
@JvmOverloads
|
||||
fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String {
|
||||
return when {
|
||||
time == null -> "??"
|
||||
isSameDate(time, now, tz) -> sameDaySdf.format(time)
|
||||
isSameYear(time, now, tz) -> sameYearSdf.format(time)
|
||||
shortFormat -> otherYearSdf.format(time)
|
||||
else -> otherYearCompleteSdf.format(time)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun isSameDate(dateOne: Date, dateTwo: Date, tz: TimeZone): Boolean {
|
||||
val calendarOne = Calendar.getInstance(tz).apply { time = dateOne }
|
||||
val calendarTwo = Calendar.getInstance(tz).apply { time = dateTwo }
|
||||
|
||||
return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) &&
|
||||
calendarOne.get(Calendar.MONTH) == calendarTwo.get(Calendar.MONTH) &&
|
||||
calendarOne.get(Calendar.DAY_OF_MONTH) == calendarTwo.get(Calendar.DAY_OF_MONTH)
|
||||
}
|
||||
|
||||
private fun isSameYear(dateOne: Date, dateTwo: Date, timeZone1: TimeZone): Boolean {
|
||||
val calendarOne = Calendar.getInstance(timeZone1).apply { time = dateOne }
|
||||
val calendarTwo = Calendar.getInstance(timeZone1).apply { time = dateTwo }
|
||||
|
||||
return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,364 +0,0 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.Pair
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.keylesspalace.tusky.R
|
||||
import de.c1710.filemojicompat.FileEmojiCompatConfig
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.ObservableEmitter
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.internal.toLongOrDefault
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
import java.io.IOException
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* This class bundles information about an emoji font as well as many convenient actions.
|
||||
*/
|
||||
class EmojiCompatFont(
|
||||
val name: String,
|
||||
private val display: String,
|
||||
@StringRes val caption: Int,
|
||||
@DrawableRes val img: Int,
|
||||
val url: String,
|
||||
// The version is stored as a String in the x.xx.xx format (to be able to compare versions)
|
||||
val version: String
|
||||
) {
|
||||
|
||||
private val versionCode = getVersionCode(version)
|
||||
|
||||
// A list of all available font files and whether they are older than the current version or not
|
||||
// They are ordered by their version codes in ascending order
|
||||
private var existingFontFileCache: List<Pair<File, List<Int>>>? = null
|
||||
|
||||
val id: Int
|
||||
get() = FONTS.indexOf(this)
|
||||
|
||||
fun getDisplay(context: Context): String {
|
||||
return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will return the actual font file (regardless of its existence) for
|
||||
* the current version (not necessarily the latest!).
|
||||
*
|
||||
* @return The font (TTF) file or null if called on SYSTEM_FONT
|
||||
*/
|
||||
private fun getFontFile(context: Context): File? {
|
||||
return if (this !== SYSTEM_DEFAULT) {
|
||||
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
|
||||
File(directory, "$name$version.ttf")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getConfig(context: Context): FileEmojiCompatConfig {
|
||||
return FileEmojiCompatConfig(context, getLatestFontFile(context))
|
||||
}
|
||||
|
||||
fun isDownloaded(context: Context): Boolean {
|
||||
return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether there is already a font version that satisfies the current version, i.e. it
|
||||
* has a higher or equal version code.
|
||||
*
|
||||
* @param context The Context
|
||||
* @return Whether there is a font file with a higher or equal version code to the current
|
||||
*/
|
||||
private fun fontFileExists(context: Context): Boolean {
|
||||
val existingFontFiles = getExistingFontFiles(context)
|
||||
return if (existingFontFiles.isNotEmpty()) {
|
||||
compareVersions(existingFontFiles.last().second, versionCode) >= 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes any older version of a font
|
||||
*
|
||||
* @param context The current Context
|
||||
*/
|
||||
private fun deleteOldVersions(context: Context) {
|
||||
val existingFontFiles = getExistingFontFiles(context)
|
||||
Log.d(TAG, "deleting old versions...")
|
||||
Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size))
|
||||
for (fileExists in existingFontFiles) {
|
||||
if (compareVersions(fileExists.second, versionCode) < 0) {
|
||||
val file = fileExists.first
|
||||
// Uses side effects!
|
||||
Log.d(
|
||||
TAG,
|
||||
String.format(
|
||||
"Deleted %s successfully: %s", file.absolutePath,
|
||||
file.delete()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all font files that are inside the files directory into an ArrayList with the information
|
||||
* on whether they are older than the currently available version or not.
|
||||
*
|
||||
* @param context The Context
|
||||
*/
|
||||
private fun getExistingFontFiles(context: Context): List<Pair<File, List<Int>>> {
|
||||
// Only load it once
|
||||
existingFontFileCache?.let {
|
||||
return it
|
||||
}
|
||||
// If we call this on the system default font, just return nothing...
|
||||
if (this === SYSTEM_DEFAULT) {
|
||||
existingFontFileCache = emptyList()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
|
||||
// It will search for old versions using a regex that matches the font's name plus
|
||||
// (if present) a version code. No version code will be regarded as version 0.
|
||||
val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern()
|
||||
val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") }
|
||||
val foundFontFiles = directory.listFiles(ttfFilter).orEmpty()
|
||||
Log.d(
|
||||
TAG,
|
||||
String.format(
|
||||
"loadExistingFontFiles: %d other font files found",
|
||||
foundFontFiles.size
|
||||
)
|
||||
)
|
||||
|
||||
return foundFontFiles.map { file ->
|
||||
val matcher = fontRegex.matcher(file.name)
|
||||
val versionCode = if (matcher.matches()) {
|
||||
val version = matcher.group(1)
|
||||
getVersionCode(version)
|
||||
} else {
|
||||
listOf(0)
|
||||
}
|
||||
Pair(file, versionCode)
|
||||
}.sortedWith { a, b ->
|
||||
compareVersions(a.second, b.second)
|
||||
}.also {
|
||||
existingFontFileCache = it
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current or latest version of this font file (if there is any)
|
||||
*
|
||||
* @param context The Context
|
||||
* @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font.
|
||||
*/
|
||||
private fun getLatestFontFile(context: Context): File? {
|
||||
val current = getFontFile(context)
|
||||
if (current != null && current.exists()) return current
|
||||
val existingFontFiles = getExistingFontFiles(context)
|
||||
return existingFontFiles.firstOrNull()?.first
|
||||
}
|
||||
|
||||
private fun getVersionCode(version: String?): List<Int> {
|
||||
if (version == null) return listOf(0)
|
||||
return version.split(".").map {
|
||||
it.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadFontFile(
|
||||
context: Context,
|
||||
okHttpClient: OkHttpClient
|
||||
): Observable<Float> {
|
||||
return Observable.create { emitter: ObservableEmitter<Float> ->
|
||||
// It is possible (and very likely) that the file does not exist yet
|
||||
val downloadFile = getFontFile(context)!!
|
||||
if (!downloadFile.exists()) {
|
||||
downloadFile.parentFile?.mkdirs()
|
||||
downloadFile.createNewFile()
|
||||
}
|
||||
val request = Request.Builder().url(url)
|
||||
.build()
|
||||
|
||||
val sink = downloadFile.sink().buffer()
|
||||
var source: Source? = null
|
||||
try {
|
||||
// Download!
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
|
||||
val responseBody = response.body
|
||||
if (response.isSuccessful && responseBody != null) {
|
||||
val size = response.length()
|
||||
var progress = 0f
|
||||
source = responseBody.source()
|
||||
try {
|
||||
while (!emitter.isDisposed) {
|
||||
sink.write(source, CHUNK_SIZE)
|
||||
progress += CHUNK_SIZE.toFloat()
|
||||
if (size > 0) {
|
||||
emitter.onNext(progress / size)
|
||||
} else {
|
||||
emitter.onNext(-1f)
|
||||
}
|
||||
}
|
||||
} catch (ex: EOFException) {
|
||||
/*
|
||||
This means we've finished downloading the file since sink.write
|
||||
will throw an EOFException when the file to be read is empty.
|
||||
*/
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Downloading $url failed. Status code: ${response.code}")
|
||||
emitter.tryOnError(Exception())
|
||||
}
|
||||
} catch (ex: IOException) {
|
||||
Log.e(TAG, "Downloading $url failed.", ex)
|
||||
downloadFile.deleteIfExists()
|
||||
emitter.tryOnError(ex)
|
||||
} finally {
|
||||
source?.close()
|
||||
sink.close()
|
||||
if (emitter.isDisposed) {
|
||||
downloadFile.deleteIfExists()
|
||||
} else {
|
||||
deleteOldVersions(context)
|
||||
emitter.onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the downloaded file, if it exists. Should be called when a download gets cancelled.
|
||||
*/
|
||||
fun deleteDownloadedFile(context: Context) {
|
||||
getFontFile(context)?.deleteIfExists()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return display
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EmojiCompatFont"
|
||||
|
||||
/**
|
||||
* This String represents the sub-directory the fonts are stored in.
|
||||
*/
|
||||
private const val DIRECTORY = "emoji"
|
||||
|
||||
private const val CHUNK_SIZE = 4096L
|
||||
|
||||
// The system font gets some special behavior...
|
||||
val SYSTEM_DEFAULT = EmojiCompatFont(
|
||||
"system-default",
|
||||
"System Default",
|
||||
R.string.caption_systememoji,
|
||||
R.drawable.ic_emoji_34dp,
|
||||
"",
|
||||
"0"
|
||||
)
|
||||
val BLOBMOJI = EmojiCompatFont(
|
||||
"Blobmoji",
|
||||
"Blobmoji",
|
||||
R.string.caption_blobmoji,
|
||||
R.drawable.ic_blobmoji,
|
||||
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
|
||||
"14.0.1"
|
||||
)
|
||||
val TWEMOJI = EmojiCompatFont(
|
||||
"Twemoji",
|
||||
"Twemoji",
|
||||
R.string.caption_twemoji,
|
||||
R.drawable.ic_twemoji,
|
||||
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
|
||||
"14.0.0"
|
||||
)
|
||||
val NOTOEMOJI = EmojiCompatFont(
|
||||
"NotoEmoji",
|
||||
"Noto Emoji",
|
||||
R.string.caption_notoemoji,
|
||||
R.drawable.ic_notoemoji,
|
||||
"https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf",
|
||||
"14.0.0"
|
||||
)
|
||||
|
||||
/**
|
||||
* This array stores all available EmojiCompat fonts.
|
||||
* References to them can simply be saved by saving their indices
|
||||
*/
|
||||
val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI)
|
||||
|
||||
/**
|
||||
* Returns the Emoji font associated with this ID
|
||||
*
|
||||
* @param id the ID of this font
|
||||
* @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range.
|
||||
*/
|
||||
fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT }
|
||||
|
||||
/**
|
||||
* Compares two version codes to each other
|
||||
*
|
||||
* @param versionA The first version
|
||||
* @param versionB The second version
|
||||
* @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise
|
||||
*/
|
||||
@VisibleForTesting
|
||||
fun compareVersions(versionA: List<Int>, versionB: List<Int>): Int {
|
||||
val len = max(versionB.size, versionA.size)
|
||||
for (i in 0 until len) {
|
||||
|
||||
val vA = versionA.getOrElse(i) { 0 }
|
||||
val vB = versionB.getOrElse(i) { 0 }
|
||||
|
||||
// It needs to be decided on the next level
|
||||
if (vA == vB) continue
|
||||
// Okay, is version B newer or version A?
|
||||
return vA.compareTo(vB)
|
||||
}
|
||||
|
||||
// The versions are equal
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is needed because when transparent compression is used OkHttp reports
|
||||
* [ResponseBody.contentLength] as -1. We try to get the header which server sent
|
||||
* us manually here.
|
||||
*
|
||||
* @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259)
|
||||
*/
|
||||
private fun Response.length(): Long {
|
||||
networkResponse?.let {
|
||||
val header = it.header("Content-Length") ?: return -1
|
||||
return header.toLongOrDefault(-1)
|
||||
}
|
||||
|
||||
// In case it's a fully cached response
|
||||
return body?.contentLength() ?: -1
|
||||
}
|
||||
|
||||
private fun File.deleteIfExists() {
|
||||
if (exists() && !delete()) {
|
||||
Log.e(TAG, "Could not delete file $this")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -34,20 +34,16 @@ import com.keylesspalace.tusky.viewdata.PollViewData
|
|||
import com.keylesspalace.tusky.viewdata.buildDescription
|
||||
import com.keylesspalace.tusky.viewdata.calculatePercent
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
|
||||
class StatusViewHelper(private val itemView: View) {
|
||||
private val absoluteTimeFormatter = AbsoluteTimeFormatter()
|
||||
|
||||
interface MediaPreviewListener {
|
||||
fun onViewMedia(v: View?, idx: Int)
|
||||
fun onContentHiddenChange(isShowing: Boolean)
|
||||
}
|
||||
|
||||
private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun setMediasPreview(
|
||||
statusDisplayOptions: StatusDisplayOptions,
|
||||
attachments: List<Attachment>,
|
||||
|
@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) {
|
|||
context.getString(R.string.poll_info_closed)
|
||||
} else {
|
||||
if (useAbsoluteTime) {
|
||||
context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt))
|
||||
context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false))
|
||||
} else {
|
||||
TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp)
|
||||
}
|
||||
|
@ -330,18 +326,6 @@ class StatusViewHelper(private val itemView: View) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getAbsoluteTime(time: Date?): String {
|
||||
return if (time != null) {
|
||||
if (android.text.format.DateUtils.isToday(time.time)) {
|
||||
shortSdf.format(time)
|
||||
} else {
|
||||
longSdf.format(time)
|
||||
}
|
||||
} else {
|
||||
"??:??:??"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val COLLAPSE_INPUT_FILTER = arrayOf<InputFilter>(SmartLengthInputFilter)
|
||||
val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
/* Copyright 2019 kyori19
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class VersionUtils {
|
||||
|
||||
private int major;
|
||||
private int minor;
|
||||
private int patch;
|
||||
|
||||
public VersionUtils(@NonNull String versionString) {
|
||||
String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*";
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
Matcher matcher = pattern.matcher(versionString);
|
||||
if (matcher.find()) {
|
||||
major = Integer.parseInt(matcher.group(1));
|
||||
minor = Integer.parseInt(matcher.group(2));
|
||||
patch = Integer.parseInt(matcher.group(3));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean supportsScheduledToots() {
|
||||
return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5.7407,0L18.2593,0A5.7407,5.7407 0,0 1,24 5.7407L24,18.2593A5.7407,5.7407 0,0 1,18.2593 24L5.7407,24A5.7407,5.7407 0,0 1,0 18.2593L0,5.7407A5.7407,5.7407 0,0 1,5.7407 0z"
|
||||
android:fillAlpha="0.75"
|
||||
android:fillColor="@color/botBadgeBackground" />
|
||||
<path
|
||||
android:fillColor="@color/botBadgeForeground"
|
||||
android:pathData="m12,3.1674a1.6059,1.6059 0,0 1,1.6059 1.6059c0,0.5942 -0.3212,1.1161 -0.803,1.3891v1.0198h0.803a5.6207,5.6207 0,0 1,5.6207 5.6207h0.803a0.803,0.803 0,0 1,0.803 0.803v2.4089a0.803,0.803 0,0 1,-0.803 0.803h-0.803v0.803a1.6059,1.6059 0,0 1,-1.6059 1.6059H6.3793A1.6059,1.6059 0,0 1,4.7733 17.6207V16.8178H3.9704A0.803,0.803 0,0 1,3.1674 16.0148V13.6059A0.803,0.803 0,0 1,3.9704 12.803H4.7733a5.6207,5.6207 0,0 1,5.6207 -5.6207h0.803V6.1625C10.7153,5.8894 10.3941,5.3675 10.3941,4.7733A1.6059,1.6059 0,0 1,12 3.1674M8.3867,12A2.0074,2.0074 0,0 0,6.3793 14.0074,2.0074 2.0074,0 0,0 8.3867,16.0148 2.0074,2.0074 0,0 0,10.3941 14.0074,2.0074 2.0074,0 0,0 8.3867,12m7.2267,0a2.0074,2.0074 0,0 0,-2.0074 2.0074,2.0074 2.0074,0 0,0 2.0074,2.0074 2.0074,2.0074 0,0 0,2.0074 -2.0074A2.0074,2.0074 0,0 0,15.6133 12Z" />
|
||||
</vector>
|
|
@ -4,6 +4,6 @@
|
|||
android:viewportHeight="24"
|
||||
android:viewportWidth="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:fillColor="@color/textColorTertiary"
|
||||
android:pathData="M20,6C20.58,6 21.05,6.2 21.42,6.59C21.8,7 22,7.45 22,8V19C22,19.55 21.8,20 21.42,20.41C21.05,20.8 20.58,21 20,21H4C3.42,21 2.95,20.8 2.58,20.41C2.2,20 2,19.55 2,19V8C2,7.45 2.2,7 2.58,6.59C2.95,6.2 3.42,6 4,6H8V4C8,3.42 8.2,2.95 8.58,2.58C8.95,2.2 9.42,2 10,2H14C14.58,2 15.05,2.2 15.42,2.58C15.8,2.95 16,3.42 16,4V6H20M4,8V19H20V8H4M14,6V4H10V6H14Z" />
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
|
||||
</vector>
|
|
@ -112,7 +112,7 @@
|
|||
app:layout_constraintStart_toStartOf="@id/guideAvatar"
|
||||
app:layout_constraintTop_toTopOf="@+id/accountFollowButton" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/accountDisplayNameTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -215,7 +215,7 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/accountNoteTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -248,63 +248,71 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.constraintlayout.widget.Group
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/accountMovedView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:constraint_referenced_ids="accountMovedText,accountMovedAvatar,accountMovedDisplayName,accountMovedUsername" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
android:id="@+id/accountMovedText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:drawablePadding="6dp"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:layout_marginTop="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
|
||||
tools:text="Account has moved" />
|
||||
tools:visibility="visible">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/accountMovedAvatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedText"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
<TextView
|
||||
android:id="@+id/accountMovedText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:drawablePadding="6dp"
|
||||
android:drawableStart="@drawable/ic_briefcase"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Account has moved" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
android:id="@+id/accountMovedDisplayName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constraintBottom_toTopOf="@id/accountMovedUsername"
|
||||
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintTop_toTopOf="@id/accountMovedAvatar"
|
||||
tools:text="Display name" />
|
||||
<ImageView
|
||||
android:importantForAccessibility="no"
|
||||
android:id="@+id/accountMovedAvatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedText"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountMovedUsername"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
|
||||
tools:text="\@username" />
|
||||
<TextView
|
||||
android:id="@+id/accountMovedDisplayName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
android:textStyle="normal|bold"
|
||||
app:layout_constraintBottom_toTopOf="@id/accountMovedUsername"
|
||||
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintTop_toTopOf="@id/accountMovedAvatar"
|
||||
tools:text="Display name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountMovedUsername"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
|
||||
tools:text="\@username" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/accountStatuses"
|
||||
|
@ -315,7 +323,7 @@
|
|||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toStartOf="@id/accountFollowing"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountStatusesTextView"
|
||||
|
@ -346,7 +354,7 @@
|
|||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toStartOf="@id/accountFollowers"
|
||||
app:layout_constraintStart_toEndOf="@id/accountStatuses"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountFollowingTextView"
|
||||
|
@ -376,7 +384,7 @@
|
|||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/accountFollowing"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
|
||||
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/accountFollowersTextView"
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
tools:text="Reply to @username"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/composeReplyContentView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -102,7 +102,7 @@
|
|||
tools:text="Quote @username"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<androidx.emoji2.widget.EmojiTextView
|
||||
android:id="@+id/composeQuoteContentView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -124,7 +124,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.emoji.widget.EmojiEditText
|
||||
<androidx.emoji2.widget.EmojiEditText
|
||||
android:id="@+id/composeContentWarningField"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:tabIndicator="@null"
|
||||
app:tabGravity="fill"
|
||||
app:tabMode="fixed" />
|
||||
|
||||
</com.google.android.material.bottomappbar.BottomAppBar>
|
||||
|
@ -98,4 +99,3 @@
|
|||
android:fitsSystemWindows="true" />
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<include
|
||||
android:id="@+id/item_blobmoji"
|
||||
layout="@layout/item_emoji_pref" />
|
||||
|
||||
<include
|
||||
android:id="@+id/item_twemoji"
|
||||
layout="@layout/item_emoji_pref" />
|
||||
|
||||
<include
|
||||
android:id="@+id/item_notoemoji"
|
||||
layout="@layout/item_emoji_pref" />
|
||||
|
||||
<include
|
||||
android:id="@+id/item_nomoji"
|
||||
layout="@layout/item_emoji_pref" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emoji_download_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="@string/download_fonts"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
|
||||
</LinearLayout>
|
|
@ -32,7 +32,7 @@
|
|||
tools:src="#000"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/account_display_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
android:paddingTop="4dp">
|
||||
|
||||
<!-- 30% width for the field name, 70% for the value -->
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/accountFieldName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -21,7 +21,7 @@
|
|||
app:layout_constraintWidth_percent=".3"
|
||||
tools:text="Field title" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/accountFieldValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/blocked_user_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
android:paddingStart="12dp"
|
||||
android:paddingEnd="14dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/conversation_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -79,7 +79,7 @@
|
|||
tools:src="#000"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -122,7 +122,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
||||
tools:text="13:37" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -157,7 +157,7 @@
|
|||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/contentWarning"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -42,7 +42,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/draftSendingInfo"
|
||||
tools:text="Some content warning" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiEditText
|
||||
<androidx.emoji2.widget.EmojiEditText
|
||||
android:id="@+id/accountFieldName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -26,7 +26,7 @@
|
|||
android:textColorHint="?android:attr/textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<androidx.emoji.widget.EmojiEditText
|
||||
<androidx.emoji2.widget.EmojiEditText
|
||||
android:id="@+id/accountFieldValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notification_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -41,7 +41,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/notification_display_name"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notification_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
android:paddingRight="16dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notificationTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -36,7 +36,7 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/notificationTextView" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/displayNameTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/muted_user_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_poll_option_result"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
android:orientation="vertical"
|
||||
app:layout_constraintGuide_begin="8dp" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/statusContentWarningDescription"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -49,7 +49,7 @@
|
|||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/statusContent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -237,7 +237,7 @@
|
|||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_poll_option_result_0"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -257,7 +257,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
|
||||
tools:text="40%" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_poll_option_result_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -277,7 +277,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
|
||||
tools:text="10%" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_poll_option_result_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -297,7 +297,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
|
||||
tools:text="20%" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_poll_option_result_3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_info"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -55,7 +55,7 @@
|
|||
tools:src="#000"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -102,7 +102,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
||||
tools:text="13:37" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -142,7 +142,7 @@
|
|||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -192,7 +192,7 @@
|
|||
android:paddingRight="6dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/card_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -203,7 +203,7 @@
|
|||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/card_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
tools:src="#000"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -79,7 +79,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
||||
tools:text="\@ConnyDuck\@mastodon.social" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -113,7 +113,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@+id/status_content_warning_description"
|
||||
tools:text="@string/post_content_warning_show_more" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -123,6 +123,7 @@
|
|||
android:hyphenationFrequency="full"
|
||||
android:importantForAccessibility="no"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
android:textIsSelectable="true"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:textSize="?attr/status_text_large"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
@ -174,7 +175,7 @@
|
|||
android:paddingRight="6dp"
|
||||
android:paddingBottom="6dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/card_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -185,7 +186,7 @@
|
|||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/card_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notification_top_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -32,7 +32,7 @@
|
|||
android:layout_toEndOf="@+id/notification_status_avatar"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/status_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -71,7 +71,7 @@
|
|||
|
||||
</RelativeLayout>
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notification_content_warning_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -102,7 +102,7 @@
|
|||
style="@style/TuskyButton.Outlined"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<TextView
|
||||
android:id="@+id/notification_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -16,6 +16,12 @@
|
|||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loginProgress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center" />
|
||||
|
||||
<WebView
|
||||
android:id="@+id/loginWebView"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/avatar_default" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<androidx.emoji2.widget.EmojiTextView
|
||||
android:id="@+id/status_quote_inline_display_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -56,7 +56,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/status_quote_inline_avatar"
|
||||
tools:text="\@ars42525\@odakyu.app" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<androidx.emoji2.widget.EmojiTextView
|
||||
android:id="@+id/status_quote_inline_content_warning_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -88,10 +88,10 @@
|
|||
android:visibility="gone"
|
||||
app:layout_constraintStart_toEndOf="@id/status_quote_inline_content_warning_description"
|
||||
app:layout_constraintTop_toTopOf="@id/status_quote_inline_content_warning_description"
|
||||
tools:text="@string/status_content_warning_show_more"
|
||||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.emoji.widget.EmojiTextView
|
||||
<androidx.emoji2.widget.EmojiTextView
|
||||
android:id="@+id/status_quote_inline_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -298,7 +298,6 @@
|
|||
<string name="send_post_notification_saved_content">تم الاحتفاظ بنسخة مِن التبويق في مسوداتك</string>
|
||||
<string name="action_compose_shortcut">حرر</string>
|
||||
<string name="error_no_custom_emojis">لا يحتوي مثيل خادومكم %s على أية حزمة إيموجي مخصصة</string>
|
||||
<string name="copy_to_clipboard_success">تم نسخه إلى الحافظة</string>
|
||||
<string name="emoji_style">نوع الإيموجي</string>
|
||||
<string name="system_default">الإفتراضي في النظام</string>
|
||||
<string name="download_fonts">يجب عليك أولا تنزيل حزمة الإيموجي هذه</string>
|
||||
|
|
|
@ -123,7 +123,6 @@
|
|||
<string name="performing_lookup_title">Извършва се търсене…</string>
|
||||
<string name="system_default">По подразбиране от системата</string>
|
||||
<string name="emoji_style">Стил на емоджи</string>
|
||||
<string name="copy_to_clipboard_success">Копирано в клипборда</string>
|
||||
<string name="error_no_custom_emojis">Инстанцията ви %s няма персонализирани емоджита</string>
|
||||
<string name="action_compose_shortcut">Композиране</string>
|
||||
<string name="send_post_notification_saved_content">Копие от публикацията е запазено във вашите чернови</string>
|
||||
|
|
|
@ -48,7 +48,6 @@
|
|||
<string name="download_fonts">আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে</string>
|
||||
<string name="system_default">সিস্টেমের ডিফল্ট</string>
|
||||
<string name="emoji_style">ইমোজি স্টাইল</string>
|
||||
<string name="copy_to_clipboard_success">ক্লিপবোর্ডে অনুলিপি করা হয়েছে</string>
|
||||
<string name="error_no_custom_emojis">আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই</string>
|
||||
<string name="action_compose_shortcut">রচনা</string>
|
||||
<string name="send_post_notification_saved_content">টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে</string>
|
||||
|
|
|
@ -304,7 +304,6 @@
|
|||
<string name="send_post_notification_saved_content">টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে</string>
|
||||
<string name="action_compose_shortcut">রচনা</string>
|
||||
<string name="error_no_custom_emojis">আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই</string>
|
||||
<string name="copy_to_clipboard_success">ক্লিপবোর্ডে অনুলিপি করা হয়েছে</string>
|
||||
<string name="emoji_style">ইমোজি স্টাইল</string>
|
||||
<string name="system_default">সিস্টেমের ডিফল্ট</string>
|
||||
<string name="download_fonts">আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে</string>
|
||||
|
|
|
@ -304,7 +304,6 @@
|
|||
<string name="send_post_notification_saved_content">Una copia del toot s\'ha guardat a esborranys</string>
|
||||
<string name="action_compose_shortcut">Escriure</string>
|
||||
<string name="error_no_custom_emojis">La teva instància %s no te emojis personalitzats</string>
|
||||
<string name="copy_to_clipboard_success">Copia al porta papers</string>
|
||||
<string name="emoji_style">Estil dels emojis</string>
|
||||
<string name="system_default">Sistema per defecte</string>
|
||||
<string name="download_fonts">Hauràs de descarregar el joc d\'emojis</string>
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<string name="title_hashtags_dialog">هاشتاگی</string>
|
||||
<string name="action_open_faved_by">پیشاندانی دڵخوازەکان</string>
|
||||
<string name="action_open_reblogged_by">پیشاندانی بەهێزکردنەکان</string>
|
||||
<string name="action_open_reblogger">کردنەوەی بەهێزکردنی نووسەر</string>
|
||||
<string name="action_open_reblogger">پۆستکەرەوەکە ببینە</string>
|
||||
<string name="action_hashtags">هاشتاگ</string>
|
||||
<string name="action_mentions">ئاماژەکان</string>
|
||||
<string name="action_links">بەستەرەکان</string>
|
||||
|
@ -57,14 +57,14 @@
|
|||
<string name="action_photo_take">وێنە بگرە</string>
|
||||
<string name="action_add_poll">زیادکردنی ڕاپرسی</string>
|
||||
<string name="action_add_media">زیادکردنی میدیا</string>
|
||||
<string name="action_open_in_web">کردنەوە لە وێبگەڕ</string>
|
||||
<string name="action_open_in_web">لە وێبگەڕ بیکەوە</string>
|
||||
<string name="action_view_media">میدیا</string>
|
||||
<string name="action_view_follow_requests">بەدواداچونی داواکاریەکان بکە</string>
|
||||
<string name="action_view_domain_mutes">دۆمەینە شاراوەکان</string>
|
||||
<string name="action_view_blocks">بەکارهێنەرە بلۆککراوەکان</string>
|
||||
<string name="action_view_mutes">بەکارهێنەرە گۆڕاوەکان</string>
|
||||
<string name="action_view_bookmarks">نیشانەکان</string>
|
||||
<string name="action_view_favourites">دڵخوازەکان</string>
|
||||
<string name="action_view_favourites">بەدڵبوونەکان</string>
|
||||
<string name="action_view_account_preferences">پەسەندکراوەکانی ئەژمێر</string>
|
||||
<string name="action_view_preferences">پەسەندەکان</string>
|
||||
<string name="action_view_profile">پرۆفایل</string>
|
||||
|
@ -76,12 +76,12 @@
|
|||
<string name="action_delete">سڕینەوە</string>
|
||||
<string name="action_edit">دەستکاری</string>
|
||||
<string name="action_report">گوزارشەکان</string>
|
||||
<string name="action_show_reblogs">پیشاندانی بەهێزکردنەکان</string>
|
||||
<string name="action_show_reblogs">پۆستکردنەوەکان نیشان بدە</string>
|
||||
<string name="action_hide_reblogs">شاردنەوەی بەهێزکردنەکان</string>
|
||||
<string name="action_unblock">بەربەست کردن لاببە</string>
|
||||
<string name="action_block">بلۆک</string>
|
||||
<string name="action_unfollow">بەدوادانەچو</string>
|
||||
<string name="action_follow">بەدواداکەوتن</string>
|
||||
<string name="action_follow">شوێنی بکەوە</string>
|
||||
<string name="action_logout_confirm">ئایا دڵنیایت لەوەی دەتەوێت بچیتەدەرەوە لە هەژماری %1$s؟</string>
|
||||
<string name="action_logout">چوونەدەرەوە</string>
|
||||
<string name="action_login">چوونەژوورەوە لەگەڵ ماستۆدۆن</string>
|
||||
|
@ -90,7 +90,7 @@
|
|||
<string name="action_unfavourite">لابردنی دڵخوازەکان</string>
|
||||
<string name="action_bookmark">نیشانه</string>
|
||||
<string name="action_favourite">دڵخواز</string>
|
||||
<string name="action_unreblog">لابردنی بەهێزکردن</string>
|
||||
<string name="action_unreblog">پۆستکردنەوەکە بگەڕێنەوە</string>
|
||||
<string name="action_reblog">بەهێزکردن</string>
|
||||
<string name="action_reply">وەڵام</string>
|
||||
<string name="action_quick_reply">وەڵامدانەوەی خێرا</string>
|
||||
|
@ -110,7 +110,7 @@
|
|||
<string name="post_sensitive_media_directions">کرتە بکە بۆ بینین</string>
|
||||
<string name="post_media_hidden_title">میدیا شاراوە</string>
|
||||
<string name="post_sensitive_media_title">ناوەڕۆکی هەستیار</string>
|
||||
<string name="post_boosted_format">%s بەرزکرا</string>
|
||||
<string name="post_boosted_format">%s پۆستی کردەوە</string>
|
||||
<string name="post_username_format">\@%s</string>
|
||||
<string name="title_licenses">مۆڵەتەکان</string>
|
||||
<string name="title_announcements">ڕاگه یه نراوەکان</string>
|
||||
|
@ -122,12 +122,12 @@
|
|||
<string name="title_mutes">بەکارهێنەرە بێدەنگ</string>
|
||||
<string name="title_bookmarks">نیشانەکان</string>
|
||||
<string name="title_favourites">دڵخوازەکان</string>
|
||||
<string name="title_followers">شوێنکەوتوان</string>
|
||||
<string name="title_follows">بەدوادا</string>
|
||||
<string name="title_followers">شوێنکەوتوو</string>
|
||||
<string name="title_follows">شوێنکەوتنەکان</string>
|
||||
<string name="title_posts_pinned">چەسپا</string>
|
||||
<string name="title_posts_with_replies">لەگەڵ وەڵامەکان</string>
|
||||
<string name="title_posts">بابەتەکان</string>
|
||||
<string name="title_view_thread">توت</string>
|
||||
<string name="title_posts">پۆست</string>
|
||||
<string name="title_view_thread">زنجیرە</string>
|
||||
<string name="title_tab_preferences">سەرخشتەکان</string>
|
||||
<string name="title_direct_messages">نامە ڕاستەوخۆکان</string>
|
||||
<string name="title_public_federated">گشتی</string>
|
||||
|
@ -140,19 +140,19 @@
|
|||
<string name="error_media_download_permission">مۆڵەت بۆ پاشکەوتکردنی میدیا پێویستە.</string>
|
||||
<string name="error_media_upload_permission">مۆڵەت بۆ خوێندنەوەی میدیا پێویستە.</string>
|
||||
<string name="error_media_upload_opening">ئەم فایلە ناتوانرێت بکرێتەوە.</string>
|
||||
<string name="error_media_upload_type">ناتوانرێت ئەو جۆرە فایلە باربکرێت.</string>
|
||||
<string name="error_audio_upload_size">فایلەکانی دەنگ دەبێت کەمتر بێت لە ٤٠MB.</string>
|
||||
<string name="error_video_upload_size">پێویستە فایلەکانی ڤیدیۆ کەمتر لە 40 مێگابایت بن.</string>
|
||||
<string name="error_image_upload_size">فایلەکە دەبێت کەمتر بێت لە 8 مێگابایت.</string>
|
||||
<string name="error_compose_character_limit">ڕەستە زۆر درێژە!</string>
|
||||
<string name="error_media_upload_type">ناتوانیت لەم جۆرە فایلانە بەرز بکەیتەوە.</string>
|
||||
<string name="error_audio_upload_size">دەبێت فایلە دەنگییەکان لە 40 مێگابایت گەورەتر نەبن.</string>
|
||||
<string name="error_video_upload_size">دەبێت ڤیدیۆکان لە 40 مێگابایت گەورەتر نەبن.</string>
|
||||
<string name="error_image_upload_size">فایلەکە دەبێت لە 8 مێگابایت بچووکتر بێت.</string>
|
||||
<string name="error_compose_character_limit">ئەم نووسینە زۆر درێژە!</string>
|
||||
<string name="error_retrieving_oauth_token">سەرکەوتوو نەبوو لە بەدەستهێنانی نیشانەی چوونەژوورەوە.</string>
|
||||
<string name="error_authorization_denied">ڕێپێدان ڕەتکرایەوە.</string>
|
||||
<string name="error_authorization_unknown">هەڵەیەک بۆ مۆڵەتدانی نەناسراو ڕووی دا.</string>
|
||||
<string name="error_no_web_browser_found">نەیتوانی وێبگەڕبدۆزێتەوە بۆ بەکارهێنان.</string>
|
||||
<string name="error_failed_app_registration">سەرکەوتوو نەبوو، ڕاستکردنەوە لەگەڵ ئەم نمونەیە.</string>
|
||||
<string name="error_invalid_domain">دۆمەینی نادروست تێنووسکرا</string>
|
||||
<string name="error_empty">ئەمە ناتوانێت بەتاڵ بێت.</string>
|
||||
<string name="error_network">هەڵەیەک لە تۆڕ ڕوویدا! تکایە پەیوەندیت بپشکنە و دوبارە هەوڵ بدە!</string>
|
||||
<string name="error_invalid_domain">دۆمەینێکی نادروستت نووسیوە</string>
|
||||
<string name="error_empty">ناکرێت ئەمە بەتاڵ بێت.</string>
|
||||
<string name="error_network">هەڵەیەک لە پەیوەندییەکەدا ڕوویدا. تکایە دڵنیا ببەوە لە بەردەستبوونی هێڵی ئینتەرنێت.</string>
|
||||
<string name="error_generic">هەڵەیەک ڕوویدا.</string>
|
||||
<string name="pref_default_post_privacy">تایبەتمەندی بابەت گریمانەیی</string>
|
||||
<string name="pref_title_http_proxy_port">دەرگای پرۆکسی HTTP</string>
|
||||
|
@ -249,7 +249,7 @@
|
|||
\n
|
||||
\nکارتێکردنی ئاگانامەکانی پاڵپێوەنان، بەڵام دەتوانیت بە پەسەندکردنە ئاگانامەکانت دا بخشێنیەوە بە دەستی.</string>
|
||||
<string name="account_note_saved">ڕزگارکرا</string>
|
||||
<string name="account_note_hint">تێبینی تایبەتی تۆ دەربارەی ئەم ئەژمێرە</string>
|
||||
<string name="account_note_hint">تێبینیی تایبەتیت بۆ ئەم هەژمارە</string>
|
||||
<string name="pref_title_wellbeing_mode">Wellbeing</string>
|
||||
<string name="pref_title_hide_top_toolbar">شاردنەوەی ناونیشانی شریتی ئامڕازی سەرەوە</string>
|
||||
<string name="pref_title_confirm_reblogs">پیشاندانی دیالۆگی دووپاتکردنەوە پێش بەهێزکردن</string>
|
||||
|
@ -294,7 +294,7 @@
|
|||
<string name="poll_ended_created">ڕاپرسییەک کە دروستت کردووە کۆتایی هات</string>
|
||||
<string name="poll_ended_voted">ڕاپرسییەک کە دەنگی پێداویت کۆتایی هات</string>
|
||||
<string name="poll_vote">دەنگ</string>
|
||||
<string name="poll_info_closed">داخراوە</string>
|
||||
<string name="poll_info_closed">کۆتایی هاتووە</string>
|
||||
<string name="poll_info_time_absolute">کۆتایی دێت لە %s</string>
|
||||
<plurals name="poll_info_people">
|
||||
<item quantity="one">%s کەس</item>
|
||||
|
@ -336,10 +336,10 @@
|
|||
<string name="conversation_2_recipients">%1$s و %2$s</string>
|
||||
<string name="conversation_1_recipients">%1$s</string>
|
||||
<string name="title_favourited_by">پەسەندکراوە لەلایەن</string>
|
||||
<string name="title_reblogged_by">بەرزکراوە لەلایەن</string>
|
||||
<string name="title_reblogged_by">پۆست کراوەتەوە لەلایەن</string>
|
||||
<plurals name="reblogs">
|
||||
<item quantity="one"><b>%s</b> بەهێزکردن</item>
|
||||
<item quantity="other"><b>%s</b> بەهێزکردن</item>
|
||||
<item quantity="one"><b>%s</b> پۆستکردنەوە</item>
|
||||
<item quantity="other"><b>%s</b> پۆستکردنەوە</item>
|
||||
</plurals>
|
||||
<plurals name="favs">
|
||||
<item quantity="one"><b>%1$s</b> دڵخواز</item>
|
||||
|
@ -375,7 +375,6 @@
|
|||
<string name="download_fonts">تۆ پێویستە سەرەتا ئەم سێتە ئیمۆجییانە دابگریت</string>
|
||||
<string name="system_default">سیستەمی بنەڕەت</string>
|
||||
<string name="emoji_style">شێوازی ئیمۆجی</string>
|
||||
<string name="copy_to_clipboard_success">ڕوونووسکراوە بۆ کلیپ بۆرد</string>
|
||||
<string name="error_no_custom_emojis">نموونەکەت %s هیچ ئیمۆجییەکی ئاسایی نییە</string>
|
||||
<string name="action_compose_shortcut">دروستکردن</string>
|
||||
<string name="send_post_notification_saved_content">کۆپیەکی دەستنووسەکە خەزن کراوە بۆ ڕەشنووسەکانت</string>
|
||||
|
|
|
@ -302,7 +302,6 @@
|
|||
<string name="send_post_notification_saved_content">Kopie vašeho tootu byla uložena do vašich konceptů</string>
|
||||
<string name="action_compose_shortcut">Napsat</string>
|
||||
<string name="error_no_custom_emojis">Vaše instance %s nemá žádná vlastní emoji</string>
|
||||
<string name="copy_to_clipboard_success">Zkopírováno do schránky</string>
|
||||
<string name="emoji_style">Styl emoji</string>
|
||||
<string name="system_default">Výchozí nastavení systému</string>
|
||||
<string name="download_fonts">Musíte si nejprve stáhnout tyto sady emoji</string>
|
||||
|
|
|
@ -251,7 +251,6 @@
|
|||
<string name="send_post_notification_saved_content">Cadwyd copi o\'r tŵt i\'ch drafftiau </string>
|
||||
<string name="action_compose_shortcut">Creu</string>
|
||||
<string name="error_no_custom_emojis">Nid oes gan eich achos %s emoji bersonol</string>
|
||||
<string name="copy_to_clipboard_success">Copïwyd i\'r clipfwrdd</string>
|
||||
<string name="emoji_style">Arddull emoji</string>
|
||||
<string name="system_default">Rhagosodiad system</string>
|
||||
<string name="download_fonts">Bydd angen i chi lawrlwytho\'r setiau emoji hyn yn gyntaf </string>
|
||||
|
|
|
@ -208,7 +208,7 @@
|
|||
<string name="notification_mention_name">Neue Erwähnungen</string>
|
||||
<string name="notification_mention_descriptions">Benachrichtigungen über neue Erwähnungen</string>
|
||||
<string name="notification_follow_name">Neue Folgende</string>
|
||||
<string name="notification_follow_description">Benachrichtigunen über neue Folgende</string>
|
||||
<string name="notification_follow_description">Benachrichtigungen über neue Folgende</string>
|
||||
<string name="notification_boost_name">Geteilte Beiträge</string>
|
||||
<string name="notification_boost_description">Benachrichtigungen, wenn deine Beiträge geteilt werden</string>
|
||||
<string name="notification_favourite_name">Favorisierte Beiträge</string>
|
||||
|
@ -279,7 +279,6 @@
|
|||
<string name="send_post_notification_saved_content">Eine Kopie des Beitrags wurde in deine Entwürfe gespeichert</string>
|
||||
<string name="action_compose_shortcut">Beitrag erstellen</string>
|
||||
<string name="error_no_custom_emojis">Deine Instanz %s hat keine Emojis definiert</string>
|
||||
<string name="copy_to_clipboard_success">In die Zwischenablage kopiert</string>
|
||||
<string name="emoji_style">Emoji-Stil</string>
|
||||
<string name="system_default">System-Standard</string>
|
||||
<string name="download_fonts">Du musst diese Emoji-Sets zunächst herunterladen</string>
|
||||
|
@ -528,4 +527,11 @@
|
|||
<string name="duration_14_days">14 Tage</string>
|
||||
<string name="duration_180_days">180 Tage</string>
|
||||
<string name="tusky_compose_post_quicksetting_label">Beitrag erstellen</string>
|
||||
<string name="notification_update_format">%s hat den Beitrag bearbeitet</string>
|
||||
<string name="pref_title_notification_filter_updates">Ein Beitrag, mit dem ich interagiert habe, wurde bearbeitet</string>
|
||||
<string name="notification_sign_up_name">Registrierungen</string>
|
||||
<string name="notification_sign_up_description">Benachrichtigungen über neue Profile</string>
|
||||
<string name="notification_sign_up_format">%s hat sich registriert</string>
|
||||
<string name="pref_title_notification_filter_sign_ups">Jemand hat sich registriert</string>
|
||||
<string name="notification_update_description">Benachrichtigungen, wenn Beiträge bearbeitet werden, mit denen du interagiert hast</string>
|
||||
</resources>
|
||||
|
|
|
@ -299,7 +299,6 @@
|
|||
<string name="send_post_notification_saved_content">Kopio de la mesaĝo estis konservita en viaj malnetoj</string>
|
||||
<string name="action_compose_shortcut">Verki</string>
|
||||
<string name="error_no_custom_emojis">Via nodo %s ne havas proprajn emoĝiojn</string>
|
||||
<string name="copy_to_clipboard_success">Kopiita en tondujo</string>
|
||||
<string name="emoji_style">Stilo de emoĝioj</string>
|
||||
<string name="system_default">Sistema valoro</string>
|
||||
<string name="download_fonts">Vi unue devos elŝuti ĉi tiujn emoĝiarojn</string>
|
||||
|
|
|
@ -269,7 +269,6 @@
|
|||
<string name="send_post_notification_saved_content">Una copia del estado se ha guardado en borradores</string>
|
||||
<string name="action_compose_shortcut">Redactar</string>
|
||||
<string name="error_no_custom_emojis">Su instancia %s no ofrece emojis personalizados</string>
|
||||
<string name="copy_to_clipboard_success">Copiado al portapapeles</string>
|
||||
<string name="emoji_style">Estilo de los emojis</string>
|
||||
<string name="system_default">Sistema</string>
|
||||
<string name="download_fonts">Tendrás que descargarlos primero</string>
|
||||
|
|
|
@ -253,7 +253,6 @@
|
|||
<string name="send_post_notification_saved_content">Tutaren kopia zirriborroetan sartu da</string>
|
||||
<string name="action_compose_shortcut">Idatzi</string>
|
||||
<string name="error_no_custom_emojis">%s instantziak ez ditu emoji pertsonalizatuak eskaintzen</string>
|
||||
<string name="copy_to_clipboard_success">Arbelean kopiatua</string>
|
||||
<string name="emoji_style">Emojien estiloa</string>
|
||||
<string name="system_default">Sistema</string>
|
||||
<string name="download_fonts">Lehenago jaitsi beharko dituzu</string>
|
||||
|
|
|
@ -248,7 +248,6 @@
|
|||
<string name="send_post_notification_saved_content">رونوشتی از بوق در پیشنویسهایتان ذخیره شد</string>
|
||||
<string name="action_compose_shortcut">ایجاد</string>
|
||||
<string name="error_no_custom_emojis">نمونهتان %s هیچ اموجی سفارشیای ندارد</string>
|
||||
<string name="copy_to_clipboard_success">در تختهگیره رونوشت شد</string>
|
||||
<string name="emoji_style">سبک اموجی</string>
|
||||
<string name="system_default">پیشگزیدهٔ سامانه</string>
|
||||
<string name="download_fonts">نخست باید این مجموعههای اموجی را بارگیری کنید</string>
|
||||
|
|
|
@ -304,7 +304,6 @@
|
|||
<string name="send_post_notification_saved_content">Une copie du pouet a été sauvegardée dans vos brouillons</string>
|
||||
<string name="action_compose_shortcut">Écrire</string>
|
||||
<string name="error_no_custom_emojis">Votre instance %s n’a pas d’émojis personnalisés</string>
|
||||
<string name="copy_to_clipboard_success">Copié dans le presse-papier</string>
|
||||
<string name="emoji_style">Style d’émojis</string>
|
||||
<string name="system_default">Par défaut du système</string>
|
||||
<string name="download_fonts">Vous devez commencer par télécharger ces jeux d’émojis</string>
|
||||
|
@ -540,4 +539,12 @@
|
|||
<string name="duration_14_days">14 jours</string>
|
||||
<string name="duration_180_days">180 jours</string>
|
||||
<string name="tusky_compose_post_quicksetting_label">Rédiger un message</string>
|
||||
<string name="notification_sign_up_format">%s a créé un compte</string>
|
||||
<string name="notification_sign_up_name">Nouveaux comptes</string>
|
||||
<string name="notification_sign_up_description">Notifications quand quelqu\'un crée un nouveau compte</string>
|
||||
<string name="pref_title_notification_filter_sign_ups">un nouveau compte a été créé</string>
|
||||
<string name="notification_update_format">%s a modifié son message</string>
|
||||
<string name="pref_title_notification_filter_updates">un message avec lequel j\'ai interagi est modifié</string>
|
||||
<string name="notification_update_name">Messages modifiés</string>
|
||||
<string name="notification_update_description">Notifications quand un post avec lequel vous avez interagi est modifié</string>
|
||||
</resources>
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
<string name="error_empty">Dit mei net leech wêze.</string>
|
||||
<string name="system_default">Systeem standert</string>
|
||||
<string name="emoji_style">Emoji styl</string>
|
||||
<string name="copy_to_clipboard_success">Nei it klemboerd kopiearre</string>
|
||||
<string name="action_compose_shortcut">Gearstelle</string>
|
||||
<string name="send_post_notification_cancel_title">Ferstjoeren ôfbrutsen</string>
|
||||
<string name="send_post_notification_channel_name">Toots oan it ferstjoeren</string>
|
||||
|
|
|
@ -339,7 +339,6 @@
|
|||
<string name="send_post_notification_saved_content">Sábháladh cóip den tút ar do dhréachtaí</string>
|
||||
<string name="action_compose_shortcut">Cum</string>
|
||||
<string name="error_no_custom_emojis">Níl aon emojis saincheaptha ag do shampla %s</string>
|
||||
<string name="copy_to_clipboard_success">Cóipeáladh chuig an gearrthaisce</string>
|
||||
<string name="emoji_style">Stíl Emoji</string>
|
||||
<string name="system_default">Réamhshocrú an chórais</string>
|
||||
<string name="download_fonts">Beidh ort na tacair emoji seo a íoslódáil ar dtús</string>
|
||||
|
|
|
@ -239,7 +239,6 @@
|
|||
<string name="download_fonts">Feumaidh tu na seataichean seo de dh’Emojis a luchdadh a-nuas an toiseach</string>
|
||||
<string name="system_default">Bun-roghainn an t-siostaim</string>
|
||||
<string name="emoji_style">Stoidhle nan Emojis</string>
|
||||
<string name="copy_to_clipboard_success">Chaidh lethbhreac dheth a chur air an stòr-bhòrd</string>
|
||||
<string name="error_no_custom_emojis">Chan eil Emojis gnàthaichte aig an ionstans %s agad</string>
|
||||
<string name="send_post_notification_saved_content">Chaidh lethbhreac dhen phost agad a shàbhaladh ’na dhreachd</string>
|
||||
<string name="send_post_notification_cancel_title">Chaidh sgur dhen chur</string>
|
||||
|
@ -249,10 +248,16 @@
|
|||
<string name="compose_save_draft">A bheil thu airson a shàbhaladh ’na dhreachd\?</string>
|
||||
<string name="lock_account_label_description">Feumaidh tu gabhail ri luchd-leantainn ùr a làimh</string>
|
||||
<string name="lock_account_label">Glais an cunntas</string>
|
||||
<string name="action_set_caption">Suidhidh am fo-thiotal</string>
|
||||
<string name="action_set_caption">Suidhich am fo-thiotal</string>
|
||||
<plurals name="hint_describe_for_visually_impaired">
|
||||
<item quantity="one">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
|
||||
\n(%d caractar(an) air a char as fhaide)</item>
|
||||
\n(%d charactar air a char as fhaide)</item>
|
||||
<item quantity="two">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
|
||||
\n(%d charactar air a char as fhaide)</item>
|
||||
<item quantity="few">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
|
||||
\n(%d caractaran air a char as fhaide)</item>
|
||||
<item quantity="other">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
|
||||
\n(%d caractar air a char as fhaide)</item>
|
||||
</plurals>
|
||||
<string name="error_failed_set_caption">Cha deach leinn am fo-thiotal a shuidheachadh</string>
|
||||
<string name="compose_active_account_description">A’ postadh leis a’ chunntas %1$s</string>
|
||||
|
@ -323,7 +328,7 @@
|
|||
<string name="abbreviated_in_hours">an ceann %du</string>
|
||||
<string name="abbreviated_in_days">an ceann %dl</string>
|
||||
<string name="abbreviated_in_years">an ceann %db</string>
|
||||
<string name="state_follow_requested">Iarrar leantainn orm</string>
|
||||
<string name="state_follow_requested">Iarrtas leantainn air</string>
|
||||
<string name="post_media_video">Videothan</string>
|
||||
<string name="post_media_images">Dealbhan</string>
|
||||
<string name="about_tusky_account">Pròifil Tusky</string>
|
||||
|
@ -541,4 +546,13 @@
|
|||
<string name="duration_14_days">14 làithean</string>
|
||||
<string name="duration_60_days">60 latha</string>
|
||||
<string name="tusky_compose_post_quicksetting_label">Sgrìobh post</string>
|
||||
<string name="notification_sign_up_format">Chlàraich %s</string>
|
||||
<string name="notification_sign_up_name">Clàraidhean</string>
|
||||
<string name="notification_sign_up_description">Brathan mu cleachdaichean ùra</string>
|
||||
<string name="pref_title_notification_filter_sign_ups">chlàraich cuideigin</string>
|
||||
<string name="notification_update_format">Dheasaich %s am post aca</string>
|
||||
<string name="notification_update_name">Deasachadh puist</string>
|
||||
<string name="notification_update_description">Brathan nuair a thèid postaichean a rinn thu conaltradh leotha a dheasachadh</string>
|
||||
<string name="pref_title_notification_filter_updates">chaidh post a rinn mi conaltradh leis a deasachadh</string>
|
||||
<string name="title_login">Clàraich a-steach</string>
|
||||
</resources>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue