Merge chats_wip

main
Alibek Omarov 4 years ago
commit 40cf08f7df
  1. 879
      app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json
  2. 2
      app/src/husky/res/values-de/husky_generated.xml
  3. 2
      app/src/husky/res/values-fa/husky_generated.xml
  4. 56
      app/src/husky/res/values-ga/husky_generated.xml
  5. 8
      app/src/husky/res/values-ja/husky_generated.xml
  6. 20
      app/src/husky/res/values-ko/husky_generated.xml
  7. 2
      app/src/husky/res/values-ru/strings.xml
  8. 9
      app/src/husky/res/values-tr/husky_generated.xml
  9. 2
      app/src/husky/res/values-zh-rCN/husky_generated.xml
  10. 2
      app/src/husky/res/values-zh-rTW/husky_generated.xml
  11. 11
      app/src/husky/res/values/strings.xml
  12. 3
      app/src/main/AndroidManifest.xml
  13. 6
      app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
  14. 11
      app/src/main/java/com/keylesspalace/tusky/TabData.kt
  15. 6
      app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
  16. 227
      app/src/main/java/com/keylesspalace/tusky/adapter/ChatMessagesAdapter.kt
  17. 199
      app/src/main/java/com/keylesspalace/tusky/adapter/ChatsAdapter.kt
  18. 15
      app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.java
  19. 2
      app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt
  20. 1047
      app/src/main/java/com/keylesspalace/tusky/components/chat/ChatActivity.kt
  21. 29
      app/src/main/java/com/keylesspalace/tusky/components/chat/ChatViewModel.kt
  22. 377
      app/src/main/java/com/keylesspalace/tusky/components/common/CommonComposeViewModel.kt
  23. 2
      app/src/main/java/com/keylesspalace/tusky/components/common/DownsizeImageTask.java
  24. 36
      app/src/main/java/com/keylesspalace/tusky/components/common/MediaUploader.kt
  25. 35
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt
  26. 328
      app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
  27. 1
      app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt
  28. 4
      app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.java
  29. 4
      app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressTextView.java
  30. 26
      app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java
  31. 21
      app/src/main/java/com/keylesspalace/tusky/db/ChatEntity.kt
  32. 21
      app/src/main/java/com/keylesspalace/tusky/db/ChatMessageEntity.kt
  33. 85
      app/src/main/java/com/keylesspalace/tusky/db/ChatsDao.kt
  34. 3
      app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt
  35. 1
      app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt
  36. 4
      app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
  37. 2
      app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt
  38. 3
      app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
  39. 4
      app/src/main/java/com/keylesspalace/tusky/di/MediaUploaderModule.kt
  40. 12
      app/src/main/java/com/keylesspalace/tusky/di/RepositoryModule.kt
  41. 6
      app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
  42. 10
      app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt
  43. 20
      app/src/main/java/com/keylesspalace/tusky/entity/Chat.kt
  44. 29
      app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt
  45. 5
      app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt
  46. 747
      app/src/main/java/com/keylesspalace/tusky/fragment/ChatsFragment.kt
  47. 4
      app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt
  48. 11
      app/src/main/java/com/keylesspalace/tusky/fragment/preference/PreferencesFragment.kt
  49. 11
      app/src/main/java/com/keylesspalace/tusky/interfaces/ChatActionListener.kt
  50. 7
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
  51. 264
      app/src/main/java/com/keylesspalace/tusky/repository/ChatRepository.kt
  52. 360
      app/src/main/java/com/keylesspalace/tusky/service/SendTootService.kt
  53. 16
      app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt
  54. 1
      app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt
  55. 33
      app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.java
  56. 9
      app/src/main/res/drawable/ic_forum_24px.xml
  57. 5
      app/src/main/res/drawable/message_background.xml
  58. 7
      app/src/main/res/drawable/unread_shape.xml
  59. 253
      app/src/main/res/layout/activity_chat.xml
  60. 22
      app/src/main/res/layout/item_chat.xml
  61. 76
      app/src/main/res/layout/item_our_message.xml
  62. 76
      app/src/main/res/layout/item_their_message.xml
  63. 7
      app/src/main/res/menu/chat_more.xml
  64. 2
      app/src/main/res/values-ber/strings.xml
  65. 46
      app/src/main/res/values-bn-rIN/strings.xml
  66. 12
      app/src/main/res/values-ca/strings.xml
  67. 38
      app/src/main/res/values-cs/strings.xml
  68. 8
      app/src/main/res/values-cy/strings.xml
  69. 39
      app/src/main/res/values-de/strings.xml
  70. 2
      app/src/main/res/values-eo/strings.xml
  71. 10
      app/src/main/res/values-es/strings.xml
  72. 12
      app/src/main/res/values-eu/strings.xml
  73. 12
      app/src/main/res/values-fa/strings.xml
  74. 10
      app/src/main/res/values-fr/strings.xml
  75. 490
      app/src/main/res/values-ga/strings.xml
  76. 2
      app/src/main/res/values-gd/strings.xml
  77. 10
      app/src/main/res/values-hu/strings.xml
  78. 2
      app/src/main/res/values-is/strings.xml
  79. 13
      app/src/main/res/values-it/strings.xml
  80. 8
      app/src/main/res/values-ja/strings.xml
  81. 1
      app/src/main/res/values-kab/strings.xml
  82. 22
      app/src/main/res/values-ko/strings.xml
  83. 2
      app/src/main/res/values-no-rNB/strings.xml
  84. 10
      app/src/main/res/values-oc/strings.xml
  85. 6
      app/src/main/res/values-pl/strings.xml
  86. 2
      app/src/main/res/values-pt-rBR/strings.xml
  87. 4
      app/src/main/res/values-ta/strings.xml
  88. 215
      app/src/main/res/values-tr/strings.xml
  89. 1
      app/src/main/res/values-vi/strings.xml
  90. 56
      app/src/main/res/values-zh-rCN/strings.xml
  91. 2
      app/src/main/res/values-zh-rHK/strings.xml
  92. 4
      app/src/main/res/values-zh-rSG/strings.xml
  93. 3
      app/src/main/res/values-zh-rTW/strings.xml
  94. 5
      app/src/main/res/values/attrs.xml
  95. 1
      app/src/main/res/values/colors.xml
  96. 12
      app/src/main/res/values/dimens.xml
  97. 19
      app/src/main/res/values/styles.xml
  98. 3
      app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt

@ -0,0 +1,879 @@
{
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "ee8ddca7a73aef753951c2e2522cbb28",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT, `formattingSyntax` TEXT NOT NULL, `markdownMode` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "formattingSyntax",
"columnName": "formattingSyntax",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "markdownMode",
"columnName": "markdownMode",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsEmojiReactions` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `defaultFormattingSyntax` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEmojiReactions",
"columnName": "notificationsEmojiReactions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultFormattingSyntax",
"columnName": "defaultFormattingSyntax",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, `chatLimit` INTEGER, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatLimit",
"columnName": "chatLimit",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ChatEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unread` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `lastMessageId` TEXT, PRIMARY KEY(`localId`, `chatId`))",
"fields": [
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "chatId",
"columnName": "chatId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"localId",
"chatId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ChatMessageEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localId` INTEGER NOT NULL, `messageId` TEXT NOT NULL, `content` TEXT, `chatId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attachment` TEXT, `emojis` TEXT NOT NULL, PRIMARY KEY(`localId`, `messageId`))",
"fields": [
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "chatId",
"columnName": "chatId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachment",
"columnName": "attachment",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"localId",
"messageId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ee8ddca7a73aef753951c2e2522cbb28')"
]
}
}

@ -12,7 +12,7 @@
<string name="license_description">Husky enthält Code und Inhalte von den folgenden Open-Source-Projekten:</string>
<string name="about_tusky_version">Husky %s</string>
<string name="about_tusky_version">test %s</string>
<string name="about_powered_by_tusky">Angetrieben durch Husky</string>

@ -34,7 +34,7 @@
<string name="action_login">با ماستودون وارد شو</string>
<string name="action_login">ورود با ماستودون</string>
<string name="add_account_description">افزودن حساب جدید ماستودون</string>

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

@ -42,12 +42,12 @@
<string name="dialog_whats_an_instance">shitposter.club, mstdn.jp, pawoo.netや<!-- --><a href="https://fediverse.network/pleroma?count=peers">その他</a><!-- -->のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
<string name="dialog_whats_an_instance">shitposter.club, blob.cat, expired.mentality.ripや<a href="https://fediverse.network/pleroma?count=peers">その他</a>のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
\nまだアカウントをお持ちでない場合は、参加したいインスタンスの名前を入力することで<!-- -->そのインスタンスにアカウントを作成できます。
\n
\n
\nインスタンスはあなたのアカウントが提供される単独の場所ですが、<!-- -->他のインスタンスのユーザーとあたかも同じ場所にいるように簡単にコミュニケーションをとったりフォローしたりできます。
\n
\n
\nさらに詳しい情報は<a href="https://joinmastodon.org">joinmastodon.org</a>でご覧いただけます。 </string>

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

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chats">Чаты</string>
<string name="action_mark_as_read">Пометить как прочитанное</string>
<string name="action_reply_to">Ответ на</string>
<!--<string name="action_mute_conversation">Заглушить разговор</string>
<string name="action_unmute_conversation">Отменить глушение разговора</string>-->

@ -3,16 +3,16 @@
<string name="about_tusky_version">Husky %s</string>
<string name="about_tusky_license">Husky özgür ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görüntüleyebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_tusky_license">Husky ücretsiz ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_tusky_account">Husky\'in Profili</string>
<string name="restart_emoji">Değişikliklerin uygulanabilmesi için uygulama yeniden başlatılmalı</string>
<string name="restart_emoji">Bu değişiklikleri uygulamak için Husky\'yi yeniden başlatmanız gerekecek</string>
<string name="license_description">Husky aşağıdakı açık kaynaklı projelerden kod ve materyal içeriyor:</string>
<string name="license_description">Husky, aşağıdaki açık kaynaklı projelerden kod ve varlıklar içerir:</string>
<string name="about_powered_by_tusky">Husky tarafından desteklenmektedir</string>
@ -38,6 +38,9 @@
<string name="add_account_description">Yeni Pleroma hesabı ekle</string>
<string name="warning_scheduling_interval">Pleroma\'un minimum 5 dakikalık zamanlama aralığı vardır.</string>

@ -43,7 +43,7 @@
<string name="add_account_description">添加新的 Pleroma 帐号</string>
<string name="warning_scheduling_interval">Pleroma的最小调度间隔为5分钟。</string>
<string name="warning_scheduling_interval">Pleroma的最小预定时间为5分钟。</string>

@ -42,7 +42,7 @@
<string name="dialog_whats_an_instance">請輸入你帳號所在的 Mastodon 站點的域名或地址</string>
<string name="dialog_whats_an_instance">"請輸入你帳號所在的 Mastodon 站點的域名或地址 "</string>
</resources>

@ -1,5 +1,9 @@
<resources>
<!-- HUSKY SPECIFIC STRINGS -->
<string name="chats">Chats</string>
<string name="chat_our_last_message">You: %s</string>
<string name="action_mark_as_read">Mark as read</string>
<string name="action_reply_to">Reply to</string>
<string name="action_emoji_react">React</string>
<string name="action_emoji_unreact">Remove reaction</string>
@ -28,6 +32,11 @@
<string name="pref_title_hide_muted_users">Hide muted users</string>
<string name="pref_title_enable_big_emojis">Enable bigger custom emojis</string>
<string name="pref_title_enable_experimental_stickers">Enable experimental Pleroma-FE stickers(if available)</string>
<string name="attachment_type_image">Image</string>
<string name="attachment_type_video">Video</string>
<string name="attachment_type_audio">Audio</string>
<string name="attachment_type_unknown">Attachment</string>
<!-- REPLACEMENT FOR TUSKY STRINGS -->
<string name="action_toggle_visibility">Post visibility</string>
@ -84,7 +93,7 @@
<string name="title_scheduled_toot">Scheduled posts</string>
<string name="title_reblogged_by">Repeated by</string>
<string name="title_view_thread">Post</string>
<!--
<string name="about_tusky_version">Husky %s</string>
<string name="about_powered_by_tusky">Powered by Husky</string>

@ -106,6 +106,9 @@
android:name=".components.compose.ComposeActivity"
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize"/>
<activity
android:name=".components.chat.ChatActivity"
android:windowSoftInputMode="stateVisible|adjustResize"/>
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />

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

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

@ -286,6 +286,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
val chatTab = createTabDataFromId(CHATS)
if (!currentTabs.contains(chatTab)) {
addableTabs.add(chatTab)
}
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
@ -343,7 +347,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
companion object {
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5
private const val MAX_TAB_COUNT = 9
}
}

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

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

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

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

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

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

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose;
package com.keylesspalace.tusky.components.common;
import android.content.ContentResolver;
import android.graphics.Bitmap;

@ -13,11 +13,13 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose
package com.keylesspalace.tusky.components.common
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
@ -132,22 +134,22 @@ class MediaUploaderImpl(
if (mediaSize > videoLimit) {
throw VideoSizeException()
}
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
PreparedMedia(QueuedMedia.VIDEO, uri, mediaSize)
}
"image" -> {
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
PreparedMedia(QueuedMedia.IMAGE, uri, mediaSize)
}
"audio" -> {
if (mediaSize > videoLimit) { // TODO: CHANGE!!11
throw AudioSizeException()
}
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
PreparedMedia(QueuedMedia.AUDIO, uri, mediaSize)
}
else -> {
if (mediaSize > videoLimit) {
throw MediaSizeException()
}
PreparedMedia(QueuedMedia.Type.UNKNOWN, uri, mediaSize)
PreparedMedia(QueuedMedia.UNKNOWN, uri, mediaSize)
// throw MediaTypeException()
}
}
@ -226,3 +228,27 @@ class MediaUploaderImpl(
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
}
}
fun Uri.toFileName(contentResolver: ContentResolver? = null): String {
var result: String = "unknown"
if(scheme.equals("content") && contentResolver != null) {
val cursor = contentResolver.query(this, null, null, null, null)
cursor?.use{
if(it.moveToFirst()) {
result = it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
}
if(result.equals("unknown")) {
path?.let {
result = it
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}

@ -23,7 +23,6 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.ContentResolver
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
@ -53,7 +52,6 @@ import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.view.inputmethod.InputContentInfoCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
@ -61,7 +59,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.SimpleTarget
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
@ -72,6 +69,7 @@ import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.common.*
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
@ -210,35 +208,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun uriToFilename(uri: Uri): String {
var result: String = "unknown"
if(uri.scheme.equals("content")) {
val cursor = contentResolver.query(uri, null, null, null, null)
cursor?.let {
try {
if(cursor.moveToFirst()) {
result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
finally {
cursor.close()
}
}
}
if(result.equals("unknown")) {
val path = uri.getPath()
path?.let {
result = path
val cut = result.lastIndexOf('/')
if (cut != -1) {
result = result.substring(cut + 1)
}
}
}
return result
}
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) {
/* Get incoming images being sent through a share action from another app. Only do this
@ -1129,7 +1098,7 @@ class ComposeActivity : BaseActivity(),
private fun pickMedia(uri: Uri, contentInfoCompat: InputContentInfoCompat? = null, filename: String? = null) {
withLifecycleContext {
viewModel.pickMedia(uri, filename ?: uriToFilename(uri)).observe { exceptionOrItem ->
viewModel.pickMedia(uri, filename ?: uri.toFileName(contentResolver)).observe { exceptionOrItem ->
contentInfoCompat?.releasePermission()

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

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

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

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

@ -29,7 +29,7 @@ import androidx.annotation.NonNull;
*/
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class}, version = 24)
TimelineAccountEntity.class, ConversationEntity.class, ChatEntity.class, ChatMessageEntity.class}, version = 25)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -37,6 +37,7 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract ChatsDao chatsDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -349,4 +350,27 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0");
}
};
public static final Migration MIGRATION_24_25 = new Migration(24, 25) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE `ChatEntity` (`localId` INTEGER NOT NULL," +
"`chatId` TEXT NOT NULL," +
"`accountId` TEXT NOT NULL," +
"`unread` INTEGER NOT NULL," +
"`updatedAt` INTEGER NOT NULL," +
"`lastMessageId` TEXT," +
"PRIMARY KEY (`localId`, `chatId`))");
database.execSQL("CREATE TABLE `ChatMessageEntity` (`localId` INTEGER NOT NULL," +
"`messageId` TEXT NOT NULL," +
"`content` TEXT," +
"`chatId` TEXT NOT NULL," +
"`accountId` TEXT NOT NULL," +
"`createdAt` INTEGER NOT NULL," +
"`attachment` TEXT," +
"`emojis` TEXT NOT NULL," +
"PRIMARY KEY (`localId`, `messageId`))");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `chatLimit` INTEGER");
}
};
}

@ -0,0 +1,21 @@
package com.keylesspalace.tusky.db
import androidx.room.*
@Entity(
primaryKeys = ["localId", "chatId"]
)
data class ChatEntity (
val localId: Long, /* our user account id */
val chatId: String,
val accountId: String,
val unread: Long,
val updatedAt: Long,
val lastMessageId: String?
)
data class ChatEntityWithAccount (
@Embedded val chat: ChatEntity,
@Embedded(prefix = "a_") val account: TimelineAccountEntity?,
@Embedded(prefix = "msg_") val lastMessage: ChatMessageEntity? = null
)

@ -0,0 +1,21 @@
package com.keylesspalace.tusky.db
import androidx.room.Entity
/*
* ChatMessage model
*/
@Entity(
primaryKeys = ["localId", "messageId"]
)
data class ChatMessageEntity(
val localId: Long,
val messageId: String,
val content: String?,
val chatId: String,
val accountId: String,
val createdAt: Long,
val attachment: String?,
val emojis: String
)

@ -0,0 +1,85 @@
package com.keylesspalace.tusky.db
import androidx.room.*
import androidx.room.OnConflictStrategy.IGNORE
import androidx.room.OnConflictStrategy.REPLACE
import io.reactivex.Single
@Dao
abstract class ChatsDao {
// TODO: must be ordering by date but it leads to issues
@Query("""SELECT c.chatId, c.localId, c.accountId, c.lastMessageId, c.unread, c.updatedAt,
a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId',
a.localUsername as 'a_localUsername', a.username as 'a_username',
a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar',
a.emojis as 'a_emojis', a.bot as 'a_bot',
msg.accountId as 'msg_accountId', msg.localId as 'msg_localId',
msg.chatId as 'msg_chatId', msg.attachment as 'msg_attachment',
msg.content as 'msg_content', msg.createdAt as 'msg_createdAt', msg.emojis as 'msg_emojis',
msg.messageId as 'msg_messageId'
FROM ChatEntity c
LEFT JOIN TimelineAccountEntity a ON (a.timelineUserId == :localId AND a.serverId = c.accountId)
LEFT JOIN ChatMessageEntity msg ON (msg.localId == :localId AND msg.chatId == c.chatId)
WHERE c.localId = :localId
AND (CASE WHEN :maxId IS NOT NULL THEN
(LENGTH(c.chatId) < LENGTH(:maxId) OR LENGTH(c.chatId) == LENGTH(:maxId) AND c.chatId < :maxId)
ELSE 1 END)
AND (CASE WHEN :sinceId IS NOT NULL THEN
(LENGTH(c.chatId) > LENGTH(:sinceId) OR LENGTH(c.chatId) == LENGTH(:sinceId) AND c.chatId > :sinceId)
ELSE 1 END)
ORDER BY c.updatedAt DESC
LIMIT :limit
""")
abstract fun getChatsForAccount(localId: Long, maxId: String?, sinceId: String?, limit: Int) : Single<List<ChatEntityWithAccount>>
@Insert(onConflict = REPLACE)
abstract fun insertChat(chatEntity: ChatEntity) : Long
@Insert(onConflict = IGNORE)
abstract fun insertChatIfNotThere(chatEntity: ChatEntity): Long
@Insert(onConflict = REPLACE)
abstract fun insertAccount(accountEntity: TimelineAccountEntity) : Long
@Insert(onConflict = REPLACE)
abstract fun insertChatMessage(chatMessageEntity: ChatMessageEntity) : Long
@Transaction
open fun insertInTransaction(chatEntity: ChatEntity, lastMessage: ChatMessageEntity?, accountEntity: TimelineAccountEntity) {
insertAccount(accountEntity)
lastMessage?.let(this::insertChatMessage)
insertChat(chatEntity)
}
@Transaction
open fun setLastMessage(accountId: Long, chatId: String, lastMessageEntity: ChatMessageEntity) {
insertChatMessage(lastMessageEntity)
setLastMessageId(accountId, chatId, lastMessageEntity.messageId)
}
@Query("""UPDATE ChatEntity SET lastMessageId = :messageId WHERE localId = :localId AND chatId = :chatId""")
abstract fun setLastMessageId(localId: Long, chatId: String, messageId: String)
@Query("""DELETE FROM ChatEntity WHERE accountId = ""
AND localId = :account AND
(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId)
AND
(LENGTH(chatId) > LENGTH(:sinceId) OR LENGTH(chatId) == LENGTH(:sinceId) AND chatId > :sinceId)
""")
abstract fun removeAllPlaceholdersBetween(account: Long, maxId: String, sinceId: String)
@Query("""DELETE FROM ChatEntity WHERE localId = :accountId AND
(LENGTH(chatId) < LENGTH(:maxId) OR LENGTH(chatId) == LENGTH(:maxId) AND chatId < :maxId)
AND
(LENGTH(chatId) > LENGTH(:minId) OR LENGTH(chatId) == LENGTH(:minId) AND chatId > :minId)
""")
abstract fun deleteRange(accountId: Long, minId: String, maxId: String)
@Query("""DELETE FROM ChatEntity WHERE localId = :localId AND accountId = :accountId""")
abstract fun deleteChatByAccount(localId: Long, accountId: String)
@Query("""DELETE FROM ChatEntity WHERE localId = :localId AND chatId = :chatId""")
abstract fun deleteChat(localId: Long, chatId: String)
}

@ -28,5 +28,6 @@ data class InstanceEntity(
val maximumTootCharacters: Int?,
val maxPollOptions: Int?,
val maxPollOptionLength: Int?,
val version: String?
val version: String?,
val chatLimit: Int?
)

@ -49,7 +49,6 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC
LIMIT :limit""")
abstract fun getStatusesForAccount(account: Long, maxId: String?, sinceId: String?, limit: Int): Single<List<TimelineStatusWithAccount>>
@Transaction
open fun insertInTransaction(status: TimelineStatusEntity, account: TimelineAccountEntity,
reblogAccount: TimelineAccountEntity?) {

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.chat.ChatActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.report.ReportActivity
@ -46,6 +47,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesComposeActivity(): ComposeActivity
@ContributesAndroidInjector
abstract fun contributesChatActivity(): ChatActivity
@ContributesAndroidInjector
abstract fun contributesEditProfileActivity(): EditProfileActivity

@ -78,7 +78,7 @@ class AppModule {
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24)
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25)
.build()
}
}

@ -49,6 +49,9 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun timelineFragment(): TimelineFragment
@ContributesAndroidInjector
abstract fun chatsFragment(): ChatsFragment
@ContributesAndroidInjector
abstract fun notificationsFragment(): NotificationsFragment

@ -16,8 +16,8 @@
package com.keylesspalace.tusky.di
import android.content.Context
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.components.common.MediaUploaderImpl
import com.keylesspalace.tusky.network.MastodonApi
import dagger.Module
import dagger.Provides

@ -4,6 +4,8 @@ import com.google.gson.Gson
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.ChatRepository
import com.keylesspalace.tusky.repository.ChatRepositoryImpl
import com.keylesspalace.tusky.repository.TimelineRepository
import com.keylesspalace.tusky.repository.TimelineRepositoryImpl
import dagger.Module
@ -20,4 +22,14 @@ class RepositoryModule {
): TimelineRepository {
return TimelineRepositoryImpl(db.timelineDao(), mastodonApi, accountManager, gson)
}
@Provides
fun providesChatRepository(
db: AppDatabase,
mastodonApi: MastodonApi,
accountManager: AccountManager,
gson: Gson
): ChatRepository {
return ChatRepositoryImpl(db.chatsDao(), mastodonApi, accountManager, gson)
}
}

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.chat.ChatViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
@ -85,5 +86,10 @@ abstract class ViewModelModule {
@ViewModelKey(ScheduledTootViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ChatViewModel::class)
internal abstract fun chatViewModel(viewModel: ChatViewModel) : ViewModel
//Add more ViewModels here
}

@ -22,6 +22,7 @@ import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.R
import kotlinx.android.parcel.Parcelize
@Parcelize
@ -62,6 +63,15 @@ data class Attachment(
}
}
fun describeAttachmentType() : Int {
return when(type) {
Type.IMAGE -> R.string.attachment_type_image
Type.VIDEO, Type.GIFV -> R.string.attachment_type_video
Type.AUDIO -> R.string.attachment_type_audio
Type.UNKNOWN -> R.string.attachment_type_unknown
}
}
/**
* The meta data of an [Attachment].
*/

@ -15,28 +15,30 @@
package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.*
data class ChatMessage(
val id: String,
val content: String,
@SerializedName("chat_id") val chatId: String,
@SerializedName("account_id") val accountId: String,
@SerializedName("created_at") val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>
val id: String,
val content: Spanned?,
@SerializedName("chat_id") val chatId: String,
@SerializedName("account_id") val accountId: String,
@SerializedName("created_at") val createdAt: Date,
val attachment: Attachment?,
val emojis: List<Emoji>,
val card: Card?
)
data class Chat(
val account: Account,
val id: String,
val unread: Int,
val unread: Long,
@SerializedName("last_message") val lastMessage: ChatMessage?,
@SerializedName("updated_at") val updatedAt: Date
)
data class NewChatMessage(
val content: String,
val media_id: String
val media_id: String?
)

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.entity
import android.os.Parcel
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.android.parcel.Parcelize
@ -23,7 +24,33 @@ data class Emoji(
val shortcode: String,
val url: String,
@SerializedName("visible_in_picker") val visibleInPicker: Boolean?
)
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
parcel.readValue(Boolean::class.java.classLoader) as? Boolean) {
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(shortcode)
parcel.writeString(url)
parcel.writeValue(visibleInPicker)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<Emoji> {
override fun createFromParcel(parcel: Parcel): Emoji {
return Emoji(parcel)
}
override fun newArray(size: Int): Array<Emoji?> {
return arrayOfNulls(size)
}
}
}
data class EmojiReaction(
val name: String,

@ -31,6 +31,11 @@ data class Instance (
@SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollLimits: PollLimits?,
@SerializedName("chat_limit") val chatLimit: Int?,
@SerializedName("avatar_upload_limit") val avatarUploadLimit: Long?,
@SerializedName("banner_upload_limit") val bannerUploadLimit: Long?,
@SerializedName("description_limit") val descriptionLimit: Int?,
@SerializedName("upload_limit") val uploadLimit: Long?,
val pleroma: InstancePleroma?
) {
override fun hashCode(): Int {

@ -0,0 +1,747 @@
package com.keylesspalace.tusky.fragment
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.arch.core.util.Function
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.*
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ChatsAdapter
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.adapter.TimelineAdapter
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.chat.ChatActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Chat
import com.keylesspalace.tusky.entity.NewChatMessage
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ChatActionListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.repository.ChatRepository
import com.keylesspalace.tusky.repository.ChatStatus
import com.keylesspalace.tusky.repository.Placeholder
import com.keylesspalace.tusky.repository.TimelineRequestMode
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Either.Left
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import com.keylesspalace.tusky.viewdata.ChatViewData
import com.uber.autodispose.AutoDispose
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_timeline.*
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class ChatsFragment : BaseFragment(), Injectable, RefreshableFragment, ReselectableFragment, ChatActionListener, OnRefreshListener {
private val TAG = "ChatsF" // logging tag
private val LOAD_AT_ONCE = 30
private val BROKEN_PAGINATION_IN_BACKEND = true // break pagination until it's not fixed in plemora
@Inject
lateinit var eventHub: EventHub
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var chatRepo: ChatRepository
@Inject
lateinit var timelineCases: TimelineCases
lateinit var adapter: ChatsAdapter
lateinit var layoutManager: LinearLayoutManager
private lateinit var scrollListener: EndlessOnScrollListener
private lateinit var bottomSheetActivity: BottomSheetActivity
private var hideFab = false
private var bottomLoading = false
private var eventRegistered = false
private var isSwipeToRefreshEnabled = true
private var isNeedRefresh = false
private var didLoadEverythingBottom = false
private var initialUpdateFailed = false
private enum class FetchEnd {
TOP, BOTTOM, MIDDLE
}
private val chats = PairedList<ChatStatus, ChatViewData?>(Function<ChatStatus, ChatViewData?> {input ->
input.asRightOrNull()?.let(ViewDataUtils::chatToViewData) ?:
ChatViewData.Placeholder(input.asLeft().id, false)
})
private val listUpdateCallback = object : ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
if (isAdded) {
Log.d(TAG, "onInserted");
adapter.notifyItemRangeInserted(position, count)
if (position == 0 && context != null) {
recyclerView.scrollToPosition(0)
}
}
}
override fun onRemoved(position: Int, count: Int) {
Log.d(TAG, "onRemoved");
adapter.notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
Log.d(TAG, "onMoved");
adapter.notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
Log.d(TAG, "onChanged");
adapter.notifyItemRangeChanged(position, count, payload)
}
}
private val diffCallback = object : DiffUtil.ItemCallback<ChatViewData>() {
override fun areItemsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean {
return oldItem.getViewDataId() == newItem.getViewDataId()
}
override fun areContentsTheSame(oldItem: ChatViewData, newItem: ChatViewData): Boolean {
return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(oldItem: ChatViewData, newItem: ChatViewData): Any? {
return if (oldItem.deepEquals(newItem)) {
//If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else // If items are different - update a whole view holder
null
}
}
private val differ = AsyncListDiffer(listUpdateCallback,
AsyncDifferConfig.Builder(diffCallback).build())
private val dataSource = object : TimelineAdapter.AdapterDataSource<ChatViewData> {
override fun getItemCount(): Int {
return differ.currentList.size
}
override fun getItemAt(pos: Int): ChatViewData {
return differ.currentList[pos]
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
val statusDisplayOptions = StatusDisplayOptions(
preferences.getBoolean("animateGifAvatars", false),
accountManager.activeAccount!!.mediaPreviewEnabled,
preferences.getBoolean("absoluteTimeView", false),
preferences.getBoolean("showBotOverlay", true),
false, CardViewMode.NONE,false
)
adapter = ChatsAdapter(dataSource, statusDisplayOptions, this, accountManager.activeAccount!!.accountId)
}
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled
swipeRefreshLayout.setOnRefreshListener(this)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
// TODO: a11y
recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(view.context)
recyclerView.layoutManager = layoutManager
recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
recyclerView.adapter = adapter
if (chats.isEmpty()) {
progressBar.visibility = View.VISIBLE
bottomLoading = true
sendInitialRequest()
} else {
progressBar.visibility = View.GONE
if (isNeedRefresh) onRefresh()
}
}
private fun sendInitialRequest() {
// debug
// sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1)
tryCache()
}
private fun tryCache() {
// Request timeline from disk to make it quick, then replace it with timeline from
// the server to update it
chatRepo.getChats(null, null, null, LOAD_AT_ONCE, TimelineRequestMode.DISK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { newChats ->
if (newChats.size > 1) {
val mutableChats = newChats.toMutableList()
mutableChats.removeAll { it.isLeft() }
chats.clear()
chats.addAll(mutableChats)
updateAdapter()
progressBar.visibility = View.GONE
}
updateCurrent()
loadAbove()
}
}
private fun updateCurrent() {
if (!BROKEN_PAGINATION_IN_BACKEND && chats.isEmpty()) {
return
}
val topId = chats.firstOrNull { it.isRight() }?.asRight()?.id
chatRepo.getChats(topId, null, null, LOAD_AT_ONCE, TimelineRequestMode.NETWORK)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ newChats ->
initialUpdateFailed = false
// When cached timeline is too old, we would replace it with nothing
if (newChats.isNotEmpty()) {
// clear old cached statuses
if(BROKEN_PAGINATION_IN_BACKEND) {
chats.clear()
} else {
chats.removeAll {
if(it.isLeft()) {
val p = it.asLeft()
p.id.length < topId!!.length || p.id < topId
} else {
val c = it.asRight()
c.id.length < topId!!.length || c.id < topId
}
}
}
chats.addAll(newChats)
updateAdapter()
}
bottomLoading = false
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
}, {
initialUpdateFailed = true
// Indicate that we are not loading anymore
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
})
}
private fun showNothing() {
statusView.visibility = View.VISIBLE
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
private fun removeAllByAccountId(accountId: String) {
chats.removeAll {
val chat = it.asRightOrNull()
chat != null && chat.account.id == accountId
}
updateAdapter()
}
private fun removeAllByInstance(instance: String) {
chats.removeAll {
val chat = it.asRightOrNull()
chat != null && LinkHelper.getDomain(chat.account.url) == instance
}
updateAdapter()
}
private fun deleteChatById(id: String) {
val iterator = chats.iterator()
while(iterator.hasNext()) {
val chat = iterator.next().asRightOrNull()
if(chat != null && chat.id == id) {
iterator.remove()
updateAdapter()
break
}
}
if(chats.isEmpty()) {
showNothing()
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
/* This is delayed until onActivityCreated solely because MainActivity.composeButton isn't
* guaranteed to be set until then. */
/* Use a modified scroll listener that both loads more statuses as it goes, and hides
* the follow button on down-scroll. */
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
hideFab = preferences.getBoolean("fabHide", false)
scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(view, dx, dy)
val activity = activity as ActionButtonActivity?
val composeButton = activity!!.actionButton
if (composeButton != null) {
if (hideFab) {
if (dy > 0 && composeButton.isShown) {
composeButton.hide() // hides the button if we're scrolling down
} else if (dy < 0 && !composeButton.isShown) {
composeButton.show() // shows it if we are scrolling up
}
} else if (!composeButton.isShown) {
composeButton.show()
}
}
}
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if(!BROKEN_PAGINATION_IN_BACKEND)
this@ChatsFragment.onLoadMore()
}
}
recyclerView.addOnScrollListener(scrollListener)
if (!eventRegistered) {
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event: Event? ->
when(event) {
is BlockEvent -> removeAllByAccountId(event.accountId)
is MuteEvent -> removeAllByAccountId(event.accountId)
is DomainMuteEvent -> removeAllByInstance(event.instance)
is StatusDeletedEvent -> deleteChatById(event.statusId)
is PreferenceChangedEvent -> onPreferenceChanged(event.preferenceKey)
}
}
eventRegistered = true
}
}
private fun onPreferenceChanged(key: String) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
when (key) {
"fabHide" -> {
hideFab = sharedPreferences.getBoolean("fabHide", false)
}
}
}
override fun onRefresh() {
if (isSwipeToRefreshEnabled)
swipeRefreshLayout.isEnabled = true
statusView.visibility = View.GONE
isNeedRefresh = false
if (this.initialUpdateFailed) {
updateCurrent()
}
loadAbove()
}
private fun loadAbove() {
if(BROKEN_PAGINATION_IN_BACKEND) {
updateCurrent()
return
}
var firstOrNull: String? = null
var secondOrNull: String? = null
for (i in chats.indices) {
val chat = chats[i]
if (chat.isRight()) {
firstOrNull = chat.asRight().id
if (i + 1 < chats.size && chats[i + 1].isRight()) {
secondOrNull = chats[i + 1].asRight().id
}
break
}
}
if (firstOrNull != null) {
sendFetchChatsRequest(null, firstOrNull, secondOrNull, FetchEnd.TOP, -1)
} else {
sendFetchChatsRequest(null, null, null, FetchEnd.BOTTOM, -1)
}
}
private fun onLoadMore() {
if (BROKEN_PAGINATION_IN_BACKEND)
updateCurrent()
return
if (didLoadEverythingBottom || bottomLoading) {
return
}
if (chats.isEmpty()) {
sendInitialRequest()
return
}
bottomLoading = true
val last = chats.last()
val placeholder: Placeholder
if (last.isRight()) {
val placeholderId = last.asRight().id.dec()
placeholder = Placeholder(placeholderId)
chats.add(Left(placeholder))
} else {
placeholder = last.asLeft()
}
chats.setPairedItem(chats.size - 1,
ChatViewData.Placeholder(placeholder.id, true))
updateAdapter()
val bottomId = chats.findLast { it.isRight() }?.let { it.asRight().id }
sendFetchChatsRequest(bottomId, null, null, FetchEnd.BOTTOM, -1)
}
private fun sendFetchChatsRequest(maxId: String?, sinceId: String?,
sinceIdMinusOne: String?,
fetchEnd: FetchEnd, pos: Int) {
if (isAdded
&& (fetchEnd == FetchEnd.TOP || fetchEnd == FetchEnd.BOTTOM && maxId == null && progressBar.visibility != View.VISIBLE)
&& !isSwipeToRefreshEnabled)
topProgressBar.show()
// allow getting old statuses/fallbacks for network only for for bottom loading
val mode = if (fetchEnd == FetchEnd.BOTTOM) {
TimelineRequestMode.ANY
} else {
TimelineRequestMode.NETWORK
}
chatRepo.getChats(maxId, sinceId, sinceIdMinusOne, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe( { result -> onFetchTimelineSuccess(result.toMutableList(), fetchEnd, pos) },
{ onFetchTimelineFailure(Exception(it), fetchEnd, pos) })
}
private fun updateChats(newChats: MutableList<ChatStatus>, fullFetch: Boolean) {
if (newChats.isEmpty()) {
updateAdapter()
return
}
if (chats.isEmpty()) {
chats.addAll(newChats)
} else {
val lastOfNew = newChats[newChats.size - 1]
val index = chats.indexOf(lastOfNew)
if (index >= 0) {
chats.subList(0, index).clear()
}
val newIndex = newChats.indexOf(chats[0])
if (newIndex == -1) {
if (index == -1 && fullFetch) {
newChats.last { it.isRight() }.let {
val placeholderId = it.asRight().id.inc()
newChats.add(Left(Placeholder(placeholderId)))
}
}
chats.addAll(0, newChats)
} else {
chats.addAll(0, newChats.subList(0, newIndex))
}
}
// Remove all consecutive placeholders
removeConsecutivePlaceholders()
updateAdapter()
}
private fun removeConsecutivePlaceholders() {
for (i in 0 until chats.size - 1) {
if (chats[i].isLeft() && chats[i + 1].isLeft()) {
chats.removeAt(i)
}
}
}
private fun replacePlaceholderWithChats(newChats: MutableList<ChatStatus>,
fullFetch: Boolean, pos: Int) {
val placeholder = chats[pos]
if (placeholder.isLeft()) {
chats.removeAt(pos)
}
if (newChats.isEmpty()) {
updateAdapter()
return
}
if (fullFetch) {
newChats.add(placeholder)
}
chats.addAll(pos, newChats)
removeConsecutivePlaceholders()
updateAdapter()
}
private fun addItems(newChats: List<ChatStatus>) {
if (newChats.isEmpty()) {
return
}
val last = chats.findLast { it.isRight() }
// I was about to replace findStatus with indexOf but it is incorrect to compare value
// types by ID anyway and we should change equals() for Status, I think, so this makes sense
if (last != null && !newChats.contains(last)) {
chats.addAll(newChats)
removeConsecutivePlaceholders()
updateAdapter()
}
}
private fun onFetchTimelineSuccess(chats: MutableList<ChatStatus>,
fetchEnd: FetchEnd, pos: Int) {
// We filled the hole (or reached the end) if the server returned less statuses than we
// we asked for.
val fullFetch = chats.size >= LOAD_AT_ONCE
when (fetchEnd) {
FetchEnd.TOP -> {
updateChats(chats, fullFetch)
}
FetchEnd.MIDDLE -> {
replacePlaceholderWithChats(chats, fullFetch, pos)
}
FetchEnd.BOTTOM -> {
if (this.chats.isNotEmpty() && !this.chats.last().isRight()) {
this.chats.removeAt(this.chats.size - 1)
updateAdapter()
}
if (chats.isNotEmpty() && !chats.last().isRight()) {
// Removing placeholder if it's the last one from the cache
chats.removeAt(chats.size - 1)
}
val oldSize = this.chats.size
if (this.chats.size > 1) {
addItems(chats)
} else {
updateChats(chats, fullFetch)
}
if (this.chats.size == oldSize) {
// This may be a brittle check but seems like it works
// Can we check it using headers somehow? Do all server support them?
didLoadEverythingBottom = true
}
}
}
if (isAdded) {
topProgressBar.hide()
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
swipeRefreshLayout.isEnabled = true
if (this.chats.size == 0) {
showNothing()
} else {
this.statusView.visibility = View.GONE
}
}
}
private fun onFetchTimelineFailure(exception: Exception, fetchEnd: FetchEnd, position: Int) {
if (isAdded) {
swipeRefreshLayout.isRefreshing = false
topProgressBar.hide()
if (fetchEnd == FetchEnd.MIDDLE && !chats[position].isRight()) {
var placeholder = chats[position].asLeftOrNull()
val newViewData: ChatViewData
if (placeholder == null) {
val chat = chats[position - 1].asRight()
val newId = chat.id.dec()
placeholder = Placeholder(newId)
}
newViewData = ChatViewData.Placeholder(placeholder.id, false)
chats.setPairedItem(position, newViewData)
updateAdapter()
} else if (chats.isEmpty()) {
swipeRefreshLayout.isEnabled = false
statusView.visibility = View.VISIBLE
if (exception is IOException) {
statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
} else {
statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
progressBar.visibility = View.VISIBLE
onRefresh()
}
}
}
Log.e(TAG, "Fetch Failure: " + exception.message)
updateBottomLoadingState(fetchEnd)
progressBar.visibility = View.GONE
}
}
private fun updateBottomLoadingState(fetchEnd: FetchEnd) {
if (fetchEnd == FetchEnd.BOTTOM) {
bottomLoading = false
}
}
override fun onLoadMore(position: Int) {
//check bounds before accessing list,
if (chats.size >= position && position > 0) {
val fromChat = chats[position - 1].asRightOrNull()
val toChat = chats[position + 1].asRightOrNull()
if (fromChat == null || toChat == null) {
Log.e(TAG, "Failed to load more at $position, wrong placeholder position")
return
}
val maxMinusOne = if (chats.size > position + 1 && chats[position + 2].isRight()) chats[position + 1].asRight().id else null
sendFetchChatsRequest(fromChat.id, toChat.id, maxMinusOne,
FetchEnd.MIDDLE, position)
val (id) = chats[position].asLeft()
val newViewData = ChatViewData.Placeholder(id, true)
chats.setPairedItem(position, newViewData)
updateAdapter()
} else {
Log.e(TAG, "error loading more")
}
}
override fun onViewAccount(id: String?) {
id?.let(bottomSheetActivity::viewAccount)
}
override fun onViewUrl(url: String?) {
url?.let { bottomSheetActivity.viewUrl(it, PostLookupFallbackBehavior.OPEN_IN_BROWSER) }
}
// never called
override fun onViewTag(tag: String?) {}
private fun updateAdapter() {
Log.d(TAG, "updateAdapter")
differ.submitList(chats.pairedCopy)
}
private fun jumpToTop() {
if (isAdded) {
layoutManager.scrollToPosition(0)
recyclerView.stopScroll()
scrollListener.reset()
}
}
override fun onReselect() {
jumpToTop()
}
override fun onResume() {
super.onResume()
startUpdateTimestamp()
}
override fun refreshContent() {
if (isAdded) onRefresh() else isNeedRefresh = true
}
/**
* Start to update adapter every minute to refresh timestamp
* If setting absoluteTimeView is false
* Auto dispose observable on pause
*/
private fun startUpdateTimestamp() {
val preferences = PreferenceManager.getDefaultSharedPreferences(activity)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { updateAdapter() }
}
}
private fun findChatPosition(id: String) : Int {
return chats.indexOfFirst { it.isRight() && it.asRight().id == id }
}
private fun markAsRead(chat: Chat) {
val pos = findChatPosition(chat.id)
val chatViewData = ViewDataUtils.chatToViewData(chat)
chats.setPairedItem(pos, chatViewData)
updateAdapter()
}
override fun onMore(id: String, v: View) {
val popup = PopupMenu(requireContext(), v)
popup.inflate(R.menu.chat_more)
val pos = findChatPosition(id)
val chat = chats[pos].asRight()
// val menu = popup.menu
popup.setOnMenuItemClickListener {
when(it.itemId) {
R.id.chat_mark_as_read -> {
api.markChatAsRead(chat.id, chat.lastMessage?.id ?: null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe({ chat -> markAsRead(chat)
}, { err -> Log.e(TAG, "Failed to mark chat as read", err) })
true
}
else -> {
false // ????
}
}
}
popup.show()
}
override fun openChat(position: Int) {
if(position < 0 || position >= chats.size)
return
val chat = chats[position].asRightOrNull()
chat?.let {
bottomSheetActivity.openChat(it)
}
}
}

@ -172,7 +172,7 @@ class ViewImageFragment : ViewMediaFragment() {
}
override fun onToolbarVisibilityChange(visible: Boolean) {
if (photoView == null || !userVisibleHint) {
if (photoView == null || !userVisibleHint || captionSheet == null) {
return
}
isDescriptionVisible = showingDescription && visible
@ -180,7 +180,7 @@ class ViewImageFragment : ViewMediaFragment() {
captionSheet.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
captionSheet.visible(isDescriptionVisible)
captionSheet?.visible(isDescriptionVisible)
animation.removeListener(this)
}
})

@ -125,6 +125,13 @@ class PreferencesFragment : PreferenceFragmentCompat() {
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.SHOW_CARDS_IN_TIMELINES
setTitle(R.string.pref_title_show_cards_in_timelines)
isSingleLineTitle = false
}
switchPreference {
setDefaultValue(true)
key = PrefKeys.SHOW_NOTIFICATIONS_FILTER
@ -141,8 +148,8 @@ class PreferencesFragment : PreferenceFragmentCompat() {
}
switchPreference {
setDefaultValue(false)
key = PrefKeys.SHOW_CARDS_IN_TIMELINES
setDefaultValue(true)
key = PrefKeys.CONFIRM_REBLOGS
setTitle(R.string.pref_title_confirm_reblogs)
isSingleLineTitle = false
}

@ -0,0 +1,11 @@
package com.keylesspalace.tusky.interfaces
import android.view.View
import com.keylesspalace.tusky.entity.Chat
interface ChatActionListener: LinkListener {
fun onLoadMore(position: Int) {}
fun onMore(chatId: String, v: View) {}
fun openChat(position: Int) {}
fun onViewMedia(position: Int, view: View?) {}
}

@ -656,14 +656,17 @@ interface MastodonApi {
@POST("api/v1/pleroma/chats/{id}/messages")
fun createChatMessage(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Path("id") chatId: String,
@Body chatMessage: NewChatMessage
): Single<ChatMessage>
): Call<ChatMessage>
@FormUrlEncoded
@POST("api/v1/pleroma/chats/{id}/read")
fun markChatAsRead(
@Path("id") chatId: String,
@Field("last_read_id") lastReadId: String
@Field("last_read_id") lastReadId: String? = null
): Single<Chat>
@POST("api/v1/pleroma/chats/by-account-id/{id}")

@ -0,0 +1,264 @@
package com.keylesspalace.tusky.repository
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.IOException
import java.util.*
typealias ChatStatus = Either<Placeholder, Chat>
typealias ChatMesssageOrPlaceholder = Either<Placeholder, ChatMessage>
interface ChatRepository {
fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int,
requestMode: TimelineRequestMode): Single<out List<ChatStatus>>
fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMesssageOrPlaceholder>>
}
class ChatRepositoryImpl(
private val chatsDao: ChatsDao,
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val gson: Gson
) : ChatRepository {
override fun getChats(maxId: String?, sinceId: String?, sincedIdMinusOne: String?,
limit: Int, requestMode: TimelineRequestMode
): Single<out List<ChatStatus>> {
val acc = accountManager.activeAccount ?: throw IllegalStateException()
val accountId = acc.id
return if (requestMode == DISK) {
this.getChatsFromDb(accountId, maxId, sinceId, limit)
} else {
getChatsFromNetwork(maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
}
}
override fun getChatMessages(chatId: String, maxId: String?, sinceId: String?, sincedIdMinusOne: String?, limit: Int, requestMode: TimelineRequestMode) : Single<out List<ChatMesssageOrPlaceholder>> {
val acc = accountManager.activeAccount ?: throw IllegalStateException()
val accountId = acc.id
/*return if (requestMode == DISK) {
getChatMessagesFromDb(chatId, accountId, maxId, sinceId, limit)
} else {
getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
}*/
return getChatMessagesFromNetwork(chatId, maxId, sinceId, sincedIdMinusOne, limit, accountId, requestMode)
}
private fun getChatsFromNetwork(maxId: String?, sinceId: String?,
sinceIdMinusOne: String?, limit: Int,
accountId: Long, requestMode: TimelineRequestMode
): Single<out List<ChatStatus>> {
return mastodonApi.getChats(maxId, null, sinceIdMinusOne, 0, limit + 1)
.map { chats ->
this.saveChatsToDb(accountId, chats, maxId, sinceId)
}
.flatMap { chats ->
this.addFromDbIfNeeded(accountId, chats, maxId, sinceId, limit, requestMode)
}
.onErrorResumeNext { error ->
if (error is IOException && requestMode != NETWORK) {
this.getChatsFromDb(accountId, maxId, sinceId, limit)
} else {
Single.error(error)
}
}
}
private fun getChatMessagesFromNetwork(chatId: String, maxId: String?, sinceId: String?,
sinceIdMinusOne: String?, limit: Int,
accountId: Long, requestMode: TimelineRequestMode
): Single<out List<ChatMesssageOrPlaceholder>> {
return mastodonApi.getChatMessages(chatId, maxId, null, sinceIdMinusOne, 0, limit + 1).map {
it.mapTo(mutableListOf(), ChatMessage::lift)
}
}
private fun addFromDbIfNeeded(accountId: Long, chats: List<ChatStatus>,
maxId: String?, sinceId: String?, limit: Int,
requestMode: TimelineRequestMode
): Single<List<ChatStatus>> {
return if (requestMode != NETWORK && chats.size < 2) {
val newMaxID = if (chats.isEmpty()) {
maxId
} else {
chats.last { it.isRight() }.asRight().id
}
this.getChatsFromDb(accountId, newMaxID, sinceId, limit)
.map { fromDb ->
// If it's just placeholders and less than limit (so we exhausted both
// db and server at this point)
if (fromDb.size < limit && fromDb.all { !it.isRight() }) {
chats
} else {
chats + fromDb
}
}
} else {
Single.just(chats)
}
}
private fun getChatsFromDb(accountId: Long, maxId: String?, sinceId: String?,
limit: Int): Single<out List<ChatStatus>> {
return chatsDao.getChatsForAccount(accountId, maxId, sinceId, limit)
.subscribeOn(Schedulers.io())
.map { chats ->
chats.map { it.toChat(gson) }
}
}
private fun saveChatsToDb(accountId: Long, chats: List<Chat>,
maxId: String?, sinceId: String?
): List<ChatStatus> {
var placeholderToInsert: Placeholder? = null
// Look for overlap
val resultChats = if (chats.isNotEmpty() && sinceId != null) {
val indexOfSince = chats.indexOfLast { it.id == sinceId }
if (indexOfSince == -1) {
// We didn't find the status which must be there. Add a placeholder
placeholderToInsert = Placeholder(sinceId.inc())
chats.mapTo(mutableListOf(), Chat::lift)
.apply {
add(Either.Left(placeholderToInsert))
}
} else {
// There was an overlap. Remove all overlapped statuses. No need for a placeholder.
chats.mapTo(mutableListOf(), Chat::lift)
.apply {
subList(indexOfSince, size).clear()
}
}
} else {
// Just a normal case.
chats.map(Chat::lift)
}
Single.fromCallable {
if(chats.isNotEmpty()) {
chatsDao.deleteRange(accountId, chats.last().id, chats.first().id)
}
for (chat in chats) {
val pair = chat.toEntity(accountId, gson)
chatsDao.insertInTransaction(
pair.first,
pair.second,
chat.account.toEntity(accountId, gson)
)
}
placeholderToInsert?.let {
chatsDao.insertChatIfNotThere(it.toChatEntity(accountId))
}
// If we're loading in the bottom insert placeholder after every load
// (for requests on next launches) but not return it.
if (sinceId == null && chats.isNotEmpty()) {
chatsDao.insertChatIfNotThere(
Placeholder(chats.last().id.dec()).toChatEntity(accountId))
}
// There may be placeholders which we thought could be from our TL but they are not
if (chats.size > 2) {
chatsDao.removeAllPlaceholdersBetween(accountId, chats.first().id,
chats.last().id)
} else if (placeholderToInsert == null && maxId != null && sinceId != null) {
chatsDao.removeAllPlaceholdersBetween(accountId, maxId, sinceId)
}
}
.subscribeOn(Schedulers.io())
.subscribe()
return resultChats
}
}
private val emojisListTypeToken = object : TypeToken<List<Emoji>>() {}
fun Placeholder.toChatEntity(timelineUserId: Long): ChatEntity {
return ChatEntity(
localId = timelineUserId,
chatId = this.id,
accountId = "",
unread = 0L,
updatedAt = 0L,
lastMessageId = null
)
}
fun ChatMessage.toEntity(timelineUserId: Long, gson: Gson) : ChatMessageEntity {
return ChatMessageEntity(
localId = timelineUserId,
messageId = this.id,
content = this.content?.toHtml(),
chatId = this.chatId,
accountId = this.accountId,
createdAt = this.createdAt.time,
attachment = this.attachment?.let { gson.toJson(it, Attachment::class.java) },
emojis = gson.toJson(this.emojis)
)
}
fun Chat.toEntity(timelineUserId: Long, gson: Gson): Pair<ChatEntity, ChatMessageEntity?> {
return Pair(ChatEntity(
localId = timelineUserId,
chatId = this.id,
accountId = this.account.id,
unread = this.unread,
updatedAt = this.updatedAt.time,
lastMessageId = this.lastMessage?.id
), this.lastMessage?.toEntity(timelineUserId, gson))
}
fun ChatMessageEntity.toChatMessage(gson: Gson) : ChatMessage {
return ChatMessage(
id = this.messageId,
content = this.content?.let { it.parseAsHtml().trimTrailingWhitespace() },
chatId = this.chatId,
accountId = this.accountId,
createdAt = Date(this.createdAt),
attachment = this.attachment?.let { gson.fromJson(it, Attachment::class.java) },
emojis = gson.fromJson(this.emojis, object : TypeToken<List<Emoji>>() {}.type ),
card = null /* don't care about card */
)
}
fun ChatEntityWithAccount.toChat(gson: Gson) : ChatStatus {
if(account == null || chat.accountId.isEmpty() || chat.updatedAt == 0L)
return Either.Left(Placeholder(chat.chatId))
return Chat(
account = this.account?.toAccount(gson) ?: Account("", "", "", "", SpannedString(""), "", "", "" ),
id = this.chat.chatId,
unread = this.chat.unread,
updatedAt = Date(this.chat.updatedAt),
lastMessage = this.lastMessage?.toChatMessage(gson)
).lift()
}
fun ChatMessage.lift(): ChatMesssageOrPlaceholder = Either.Right(this)
fun Chat.lift(): ChatStatus = Either.Right(this)

@ -11,7 +11,6 @@ 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
@ -22,10 +21,9 @@ import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.SaveTootHelper
import dagger.android.AndroidInjection
import kotlinx.android.parcel.Parcelize
@ -51,8 +49,8 @@ class SendTootService : Service(), Injectable {
@Inject
lateinit var saveTootHelper: SaveTootHelper
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
private val tootsToSend = ConcurrentHashMap<Int, PostToSend>()
private val sendCalls = ConcurrentHashMap<Int, Either<Call<Status>, Call<ChatMessage>>>()
private val timer = Timer()
@ -68,59 +66,49 @@ class SendTootService : Service(), Injectable {
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
return START_NOT_STICKY
}
if (intent.hasExtra(KEY_TOOT)) {
val tootToSend = intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: throw IllegalStateException("SendTootService started without $KEY_TOOT extra")
if (NotificationHelper.NOTIFICATION_USE_CHANNELS) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
var notificationText = tootToSend.warningText
if (notificationText.isBlank()) {
notificationText = tootToSend.text
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentText(notificationText)
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {
notificationManager.notify(sendingNotificationId, builder.build())
}
val postToSend : PostToSend = (intent.getParcelableExtra<TootToSend>(KEY_TOOT)
?: intent.getParcelableExtra<MessageToSend>(KEY_CHATMSG)) as PostToSend?
?: throw IllegalStateException("SendTootService started without $KEY_CHATMSG or $KEY_TOOT extra")
tootsToSend[sendingNotificationId] = tootToSend
sendToot(sendingNotificationId--)
if (NotificationHelper.NOTIFICATION_USE_CHANNELS) {
val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_toot_notification_channel_name), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_title))
.setContentText(postToSend.getNotificationText())
.setProgress(1, 0, true)
.setOngoing(true)
.setColor(ContextCompat.getColor(this, R.color.tusky_blue))
.addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId))
if (tootsToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
startForeground(sendingNotificationId, builder.build())
} else {
if (intent.hasExtra(KEY_CANCEL)) {
cancelSending(intent.getIntExtra(KEY_CANCEL, 0))
}
notificationManager.notify(sendingNotificationId, builder.build())
}
return START_NOT_STICKY
tootsToSend[sendingNotificationId] = postToSend
sendToot(sendingNotificationId--)
return START_NOT_STICKY
}
private fun sendToot(tootId: Int) {
// when tootToSend == null, sending has been canceled
val tootToSend = tootsToSend[tootId] ?: return
val postToSend = tootsToSend[tootId] ?: return
// when account == null, user has logged out, cancel sending
val account = accountManager.getAccountById(tootToSend.accountId)
val account = accountManager.getAccountById(postToSend.getAccountId())
if (account == null) {
tootsToSend.remove(tootId)
@ -129,87 +117,135 @@ class SendTootService : Service(), Injectable {
return
}
tootToSend.retries++
val contentType : String? = if(tootToSend.formattingSyntax.isNotEmpty()) tootToSend.formattingSyntax else null
val preview : Boolean? = if(tootToSend.preview) true else null
val newStatus = NewStatus(
tootToSend.text,
tootToSend.warningText,
tootToSend.inReplyToId,
tootToSend.visibility,
tootToSend.sensitive,
tootToSend.mediaIds,
tootToSend.scheduledAt,
tootToSend.poll,
contentType,
preview
)
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
tootToSend.idempotencyKey,
newStatus
)
sendCalls[tootId] = sendCall
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
val scheduled = !tootToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (tootToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(tootToSend.savedTootUid)
}
postToSend.incrementRetries()
if(postToSend is TootToSend) {
val contentType : String? = if(postToSend.formattingSyntax.isNotEmpty()) postToSend.formattingSyntax else null
val preview : Boolean? = if(postToSend.preview) true else null
val newStatus = NewStatus(
postToSend.text,
postToSend.warningText,
postToSend.inReplyToId,
postToSend.visibility,
postToSend.sensitive,
postToSend.mediaIds,
postToSend.scheduledAt,
postToSend.poll,
contentType,
preview
)
val sendCall = mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
postToSend.idempotencyKey,
newStatus
)
val callback = object : Callback<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
val scheduled = !postToSend.scheduledAt.isNullOrEmpty()
tootsToSend.remove(tootId)
if (response.isSuccessful) {
// If the status was loaded from a draft, delete the draft and associated media files.
if (postToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(postToSend.savedTootUid)
}
when {
tootToSend.preview -> response.body()?.let(::StatusPreviewEvent)?.let(eventHub::dispatch)
scheduled -> response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
else -> response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(tootId)
when {
postToSend.preview -> response.body()?.let(::StatusPreviewEvent)?.let(eventHub::dispatch)
scheduled -> response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
else -> response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch)
}
notificationManager.cancel(tootId)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(tootToSend)
} else {
// the server refused to accept the toot, save toot & show error message
saveTootToDrafts(postToSend)
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
}
stopSelfWhenDone()
}
stopSelfWhenDone()
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
}
}, backoff)
}
}
override fun onFailure(call: Call<Status>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(tootToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
sendCalls[tootId] = Either.Left(sendCall)
sendCall.enqueue(callback)
} else if(postToSend is MessageToSend) {
val newMessage = NewChatMessage(postToSend.text, postToSend.mediaId)
val sendCall = mastodonApi.createChatMessage(
"Bearer " + account.accessToken,
account.domain,
postToSend.chatId,
newMessage
)
val callback = object : Callback<ChatMessage> {
override fun onResponse(call: Call<ChatMessage>, response: Response<ChatMessage>) {
tootsToSend.remove(tootId)
if (response.isSuccessful) {
notificationManager.cancel(tootId)
eventHub.dispatch(ChatMessageDeliveredEvent(response.body()!!))
} else {
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(getString(R.string.send_toot_notification_error_title))
.setContentText(getString(R.string.send_toot_notification_saved_content))
.setColor(ContextCompat.getColor(this@SendTootService, R.color.tusky_blue))
notificationManager.cancel(tootId)
notificationManager.notify(errorNotificationId--, builder.build())
}
stopSelfWhenDone()
}
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
override fun onFailure(call: Call<ChatMessage>, t: Throwable) {
var backoff = TimeUnit.SECONDS.toMillis(postToSend.retries.toLong())
if (backoff > MAX_RETRY_INTERVAL) {
backoff = MAX_RETRY_INTERVAL
}
}, backoff)
}
}
sendCall.enqueue(callback)
timer.schedule(object : TimerTask() {
override fun run() {
sendToot(tootId)
}
}, backoff)
}
}
sendCalls[tootId] = Either.Right(sendCall)
sendCall.enqueue(callback)
}
}
private fun stopSelfWhenDone() {
@ -224,9 +260,18 @@ class SendTootService : Service(), Injectable {
val tootToCancel = tootsToSend.remove(tootId)
if (tootToCancel != null) {
val sendCall = sendCalls.remove(tootId)
sendCall?.cancel()
saveTootToDrafts(tootToCancel)
sendCall?.let {
if(it.isLeft()) {
val sendStatusCall = it.asLeft()
sendStatusCall.cancel()
saveTootToDrafts(tootToCancel as TootToSend)
} else {
val sendMessageCall = it.asRight()
sendMessageCall.cancel()
}
}
val builder = NotificationCompat.Builder(this@SendTootService, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notify)
@ -274,6 +319,7 @@ class SendTootService : Service(), Injectable {
companion object {
private const val KEY_CHATMSG = "chatmsg"
private const val KEY_TOOT = "toot"
private const val KEY_CANCEL = "cancel_id"
private const val CHANNEL_ID = "send_toots"
@ -283,29 +329,35 @@ class SendTootService : Service(), Injectable {
private var sendingNotificationId = -1 // use negative ids to not clash with other notis
private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis
private fun Intent.forwardUriPermissions(mediaUris: List<String>) {
if(mediaUris.isEmpty())
return
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
ClipData.Item(mediaUris[0])
)
mediaUris.drop(1).forEach { uriClip.addItem(ClipData.Item(it)) }
clipData = uriClip
}
@JvmStatic
fun sendTootIntent(context: Context,
tootToSend: TootToSend
): Intent {
fun sendMessageIntent(context: Context, msgToSend: MessageToSend): Intent {
val intent = Intent(context, SendTootService::class.java)
intent.putExtra(KEY_TOOT, tootToSend)
intent.putExtra(KEY_CHATMSG, msgToSend)
if(msgToSend.mediaUri != null)
intent.forwardUriPermissions(listOf(msgToSend.mediaUri))
if (tootToSend.mediaUris.isNotEmpty()) {
// forward uri permissions
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val uriClip = ClipData(
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
ClipData.Item(tootToSend.mediaUris[0])
)
tootToSend.mediaUris
.drop(1)
.forEach { mediaUri ->
uriClip.addItem(ClipData.Item(mediaUri))
}
intent.clipData = uriClip
return intent
}
}
@JvmStatic
fun sendTootIntent(context: Context, tootToSend: TootToSend): Intent {
val intent = Intent(context, SendTootService::class.java)
intent.putExtra(KEY_TOOT, tootToSend)
intent.forwardUriPermissions(tootToSend.mediaUris)
return intent
}
@ -313,6 +365,34 @@ class SendTootService : Service(), Injectable {
}
}
interface PostToSend {
fun getAccountId() : Long
fun getNotificationText() : String
fun incrementRetries()
}
@Parcelize
data class MessageToSend(
val text: String,
val mediaId: String?,
val mediaUri: String?,
private val accountId: Long,
val chatId: String,
var retries: Int
) : Parcelable, PostToSend {
override fun getAccountId(): Long {
return accountId
}
override fun getNotificationText() : String {
return text
}
override fun incrementRetries() {
retries++
}
}
@Parcelize
data class TootToSend(
val text: String,
@ -330,8 +410,20 @@ data class TootToSend(
val savedJsonUrls: List<String>?,
val formattingSyntax: String,
val preview: Boolean,
val accountId: Long,
private val accountId: Long,
val savedTootUid: Int,
val idempotencyKey: String,
var retries: Int
) : Parcelable
) : Parcelable, PostToSend {
override fun getNotificationText() : String {
return if(warningText.isBlank()) text else warningText
}
override fun getAccountId() : Long {
return accountId
}
override fun incrementRetries() {
retries++
}
}

@ -16,19 +16,31 @@
package com.keylesspalace.tusky.service
import android.content.Context
import android.content.Intent
import android.os.Build
interface ServiceClient {
fun sendToot(tootToSend: TootToSend)
fun sendChatMessage(msgToSend: MessageToSend)
}
class ServiceClientImpl(private val context: Context) : ServiceClient {
override fun sendToot(tootToSend: TootToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend)
private fun startService(intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
override fun sendToot(tootToSend: TootToSend) {
val intent = SendTootService.sendTootIntent(context, tootToSend)
startService(intent)
}
override fun sendChatMessage(msgToSend: MessageToSend) {
val intent = SendTootService.sendMessageIntent(context, msgToSend)
startService(intent)
}
}

@ -28,6 +28,7 @@ object PrefKeys {
const val USE_BLURHASH = "useBlurhash"
const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter"
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
const val CONFIRM_REBLOGS = "confirmReblogs"
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
const val BIG_EMOJIS = "bigEmojis"
const val STICKERS = "stickers"

@ -15,12 +15,17 @@
package com.keylesspalace.tusky.util;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.Chat;
import com.keylesspalace.tusky.entity.ChatMessage;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.keylesspalace.tusky.viewdata.ChatViewData;
import com.keylesspalace.tusky.viewdata.ChatMessageViewData;
/**
* Created by charlag on 12/07/2017.
@ -88,4 +93,32 @@ public final class ViewDataUtils {
notification.getEmoji()
);
}
public static ChatMessageViewData.Concrete chatMessageToViewData(@Nullable ChatMessage msg) {
if(msg == null) return null;
return new ChatMessageViewData.Concrete(
msg.getId(),
msg.getContent(),
msg.getChatId(),
msg.getAccountId(),
msg.getCreatedAt(),
msg.getAttachment(),
msg.getEmojis(),
msg.getCard()
);
}
@NonNull
public static ChatViewData.Concrete chatToViewData(Chat chat) {
return new ChatViewData.Concrete(
chat.getAccount(),
chat.getId(),
chat.getUnread(),
chatMessageToViewData(
chat.getLastMessage()
),
chat.getUpdatedAt()
);
}
}

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M21,6h-2v9L6,15v2c0,0.55 0.45,1 1,1h11l4,4L22,7c0,-0.55 -0.45,-1 -1,-1zM17,12L17,3c0,-0.55 -0.45,-1 -1,-1L3,2c-0.55,0 -1,0.45 -1,1v14l4,-4h10c0.55,0 1,-0.45 1,-1z"/>
</vector>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="?attr/chat_me_color" />
<corners android:radius="@dimen/chat_radius" />
</shape>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/red" />
<size
android:height="?attr/status_text_medium"
android:width="?attr/status_text_medium" />
</shape>

@ -0,0 +1,253 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activityChat"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation"
app:layout_collapseMode="pin">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
>
<ImageView
android:id="@+id/chatAvatar"
android:layout_width="?attr/actionBarSize"
android:layout_height="?attr/actionBarSize"
android:padding="8dp"
android:contentDescription="@string/action_view_profile"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/chatTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:paddingEnd="@dimen/status_display_name_padding_end"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
tools:text="Ente r the void you foooooo" />
<TextView
android:id="@+id/chatUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:importantForAccessibility="no"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_large"
tools:text="\@Entenhausen@birbsarecooooooooooool.site" />
</androidx.appcompat.widget.Toolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/composeLayout"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/composeLayout"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/composeLayout"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
tools:visibility="visible"
app:layout_constrainedHeight="true" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/composeLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="?attr/colorSurface"
android:animateLayoutChanges="true"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:layout_constraintTop_toBottomOf="@+id/recycler"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="@dimen/compose_media_preview_size"
android:layout_height="@dimen/compose_media_preview_size"
android:layout_margin="@dimen/compose_media_preview_margin_bottom"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
tools:visibility="visible">
<com.keylesspalace.tusky.components.compose.view.ProgressImageView
android:id="@+id/imageAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible" />
<com.keylesspalace.tusky.components.compose.view.ProgressTextView
android:id="@+id/textAttachment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:ellipsize="marquee"
android:marqueeRepeatLimit="-1"
android:singleLine="true"
android:textSize="?attr/status_text_small"
tools:visibility="visible" />
</FrameLayout>
<ImageButton
android:id="@+id/attachmentButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:contentDescription="@string/action_add_media"
android:tooltipText="@string/action_add_media"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:srcCompat="@drawable/ic_attach_file_24dp" />
<com.keylesspalace.tusky.components.compose.view.EditTextTyped
android:id="@+id/editText"
android:layout_width="0dp"
android:layout_height="0dp"
android:textSize="?attr/status_text_large"
android:singleLine="false"
android:background="@null"
android:completionThreshold="2"
android:dropDownWidth="wrap_content"
android:hint="@string/hint_compose"
android:inputType="text|textMultiLine|textCapSentences"
android:lineSpacingMultiplier="1.1"
android:textColorHint="?android:attr/textColorTertiary"
app:layout_constraintEnd_toStartOf="@+id/emojiButton"
app:layout_constraintStart_toEndOf="@+id/attachmentButton"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintBottom_toBottomOf="parent"
tools:text="Just landed in L.A." />
<ImageButton
android:id="@+id/emojiButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/action_emoji_keyboard"
android:tooltipText="@string/action_emoji_keyboard"
app:srcCompat="@drawable/ic_emoji_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toLeftOf="@id/stickerButton"
/>
<ImageButton
android:id="@+id/stickerButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/action_sticker"
android:tooltipText="@string/action_sticker"
app:srcCompat="@drawable/ic_sticker"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toLeftOf="@id/sendButton"
/>
<ImageButton
android:id="@+id/sendButton"
style="@style/TuskyImageButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/action_send"
android:tooltipText="@string/action_send"
app:srcCompat="@drawable/ic_send_24dp"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintRight_toRightOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/emojiView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:elevation="12dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<com.keylesspalace.tusky.view.EmojiKeyboard
android:id="@+id/stickerKeyboard"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="?attr/colorSurface"
android:elevation="12dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<LinearLayout
android:id="@+id/addMediaBottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:elevation="12dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
android:id="@+id/actionPhotoTake"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:padding="8dp"
android:text="@string/action_photo_take"
android:textSize="?attr/status_text_medium" />
<TextView
android:id="@+id/actionPhotoPick"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="8dp"
android:padding="8dp"
android:text="@string/action_add_media"
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<include layout="@layout/item_status_bottom_sheet" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -12,7 +12,9 @@
android:focusable="true"
android:paddingLeft="14dp"
android:paddingRight="14dp"
android:paddingBottom="8dp">
android:paddingBottom="8dp"
android:clickable="true"
android:longClickable="true">
<ImageView
android:id="@+id/status_avatar"
@ -98,9 +100,25 @@
android:textSize="?attr/status_text_medium"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/chat_unread"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_display_name"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." />
<TextView
android:id="@+id/chat_unread"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/unread_shape"
android:textColor="@color/white"
android:textSize="?attr/status_text_small"
android:ellipsize="none"
android:gravity="center"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toBottomOf="@id/status_display_name"
app:layout_constraintBaseline_toBaselineOf="@id/status_content"
app:layout_constraintEnd_toEndOf="parent"
tools:text="1"
android:singleLine="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/chat_message_h_padding"
android:layout_marginBottom="@dimen/chat_message_v_padding">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_background"
android:backgroundTint="@color/colorBackgroundAccent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.8"
>
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="@dimen/chat_message_max_width"
android:layout_height="@dimen/chat_media_preview_item_height"
android:layout_marginTop="@dimen/chat_message_h_padding"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
>
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/attachment"
android:layout_width="match_parent"
android:layout_height="@dimen/chat_media_preview_item_height"
tools:src="@drawable/elephant_friend_empty" />
<ImageView
android:id="@+id/mediaOverlay"
android:layout_width="match_parent"
android:layout_height="@dimen/chat_media_preview_item_height"
android:scaleType="center"
app:srcCompat="@drawable/ic_play_indicator"
tools:ignore="ContentDescription" />
</FrameLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="@dimen/chat_message_v_padding"
android:textColor="@color/textColorPrimary"
android:textSize="?attr/status_text_large"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="50dp"
android:paddingBottom="@dimen/chat_message_v_padding"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintBottom_toBottomOf="@id/datetime"
app:layout_constraintStart_toStartOf="parent"
tools:text="MeowMeowMeow" />
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="12:39"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/chat_message_h_padding"
android:layout_marginBottom="@dimen/chat_message_h_padding">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_background"
android:backgroundTint="?attr/colorPrimaryDark"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintWidth_max="wrap"
app:layout_constraintWidth_percent="0.8"
>
<FrameLayout
android:id="@+id/attachmentLayout"
android:layout_width="@dimen/chat_message_max_width"
android:layout_height="@dimen/chat_media_preview_item_height"
android:layout_marginTop="@dimen/chat_message_h_padding"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
>
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/attachment"
android:layout_width="match_parent"
android:layout_height="@dimen/chat_media_preview_item_height"
tools:src="@drawable/elephant_friend_empty" />
<ImageView
android:id="@+id/mediaOverlay"
android:layout_width="match_parent"
android:layout_height="@dimen/chat_media_preview_item_height"
android:scaleType="center"
app:srcCompat="@drawable/ic_play_indicator"
tools:ignore="ContentDescription" />
</FrameLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingTop="@dimen/chat_message_v_padding"
android:textColor="@color/textColorPrimary"
android:textSize="?attr/status_text_large"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="50dp"
android:paddingBottom="@dimen/chat_message_v_padding"
app:layout_constraintTop_toBottomOf="@id/attachmentLayout"
app:layout_constraintBottom_toBottomOf="@id/datetime"
app:layout_constraintStart_toStartOf="parent"
tools:text="MeowMeowMeow" />
<TextView
android:id="@+id/datetime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:paddingStart="@dimen/chat_message_h_padding"
android:paddingEnd="@dimen/chat_message_h_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:text="12:39"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/chat_mark_as_read"
android:title="@string/action_mark_as_read"
/>
</menu>

@ -8,7 +8,7 @@
<string name="action_view_favourites">ⵉⵙⵎⴻⵏⵢⵉⴼⴻⵏ</string>
<string name="action_view_profile">ⴰⵎⴻⵖⵏⵓ</string>
<string name="action_close">ⵎⴷⴻⵍ</string>
<string name="error_generic">ⵝⴻⵍⵍⴰ ⴷ ⵝⵓⵛⴹⴰ.</string>
<string name="error_generic">ⵝⴻⵍⵍⴰ ⴷ ⵝⵓⵛⴹⴰ</string>
<string name="title_lists">ⵝⴰⴲⴸⴰⵔⵉⵏ</string>
<string name="action_lists">ⵝⵉⴲⴸⴰⵔⵉⵏ</string>
<string name="about_title_activity">ⵖⴻⴼ</string>

@ -48,7 +48,7 @@
<string name="status_content_show_more">ি</string>
<string name="status_content_show_less">বন</string>
<string name="message_empty">এখিই নই।</string>
<string name="footer_empty">এখিিশ করতিন!</string>
<string name="footer_empty">এখি.িশ করতিন!</string>
<string name="notification_reblog_format">%s সমরথন দি</string>
<string name="notification_favourite_format">%s আপনর টট পছনদ কর</string>
<string name="notification_follow_format">%s আপন অনসরণ কর</string>
@ -136,7 +136,7 @@
<string name="hint_content_warning">সতরকব</string>
<string name="hint_display_name">রদরশন ন</string>
<string name="hint_note">বন</string>
<string name="hint_search">অনসন</string>
<string name="hint_search">অনসন</string>
<string name="search_no_results">ন ফলফল ন</string>
<string name="label_quick_reply">উততর…</string>
<string name="label_avatar">অবত</string>
@ -424,7 +424,45 @@
<string name="dialog_mute_warning">িশবদ @%s\?</string>
<string name="dialog_block_warning">অবরধ @%s\?</string>
<plurals name="poll_timespan_days">
<item quantity="one"></item>
<item quantity="other"></item>
<item quantity="one">%d দি</item>
<item quantity="other">%d দি</item>
</plurals>
<string name="pref_title_gradient_for_media">িির জনয রঙিন গিট বযবহর করি</string>
<string name="action_unmute_conversation">আলপ বনধ কর</string>
<string name="action_mute_conversation">আলপ বনধ কর</string>
<string name="action_unmute_domain">আনমিউট %s</string>
<string name="action_mute_notifications_desc">%s থিঞপি বনধ কর</string>
<string name="action_unmute_notifications_desc">%s থিঞপি বনধ কর</string>
<string name="action_unmute_desc">আনমিউট %s</string>
<string name="notification_follow_request_name">অনধ অনসরণ কর</string>
<string name="pref_title_confirm_reblogs">ট করর আগিিত কর</string>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d স</item>
<item quantity="other">%d স</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d মিি</item>
<item quantity="other">%d মিি</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d ঘন</item>
<item quantity="other">%d ঘন</item>
</plurals>
<plurals name="poll_info_people">
<item quantity="one">%s জন</item>
<item quantity="other">%s জন</item>
</plurals>
<plurals name="poll_info_votes">
<item quantity="one">%sটি</item>
<item quantity="other">%sটি</item>
</plurals>
<string name="list">ি</string>
<string name="select_list_title">িিচন কর</string>
<string name="hashtags">শট</string>
<string name="add_hashtag_title">শটগ যগ কর</string>
<string name="description_status_bookmarked">কমকক</string>
<string name="notification_follow_request_description">অনসরণ রির বিঞপি</string>
<string name="pref_main_nav_position_option_bottom">সবচ</string>
<string name="pref_main_nav_position_option_top">সরবপরথম</string>
<string name="pref_main_nav_position">ল নিশন জ</string>
</resources>

@ -46,7 +46,7 @@
<string name="action_reblog">Retooteja</string>
<string name="action_favourite">Preferit</string>
<string name="action_more">Més</string>
<string name="action_compose">Redacta</string>
<string name="action_compose">Escriure</string>
<string name="action_login">Inicia sessió amb Mastodon</string>
<string name="action_logout">Tanca la sessió</string>
<string name="action_follow">Segueix</string>
@ -90,7 +90,7 @@
<string name="confirmation_unmuted">Usuari sense silenciar</string>
<string name="hint_domain">Quina instància?</string>
<string name="hint_compose">Què està passant?</string>
<string name="hint_content_warning">Avís de contingut</string>
<string name="hint_content_warning">Contingut sensible</string>
<string name="hint_display_name">Nom visible</string>
<string name="hint_note">Biografia</string>
<string name="hint_search">Cerca…</string>
@ -206,7 +206,7 @@
<string name="error_video_upload_size">Els fitxers de vídeo han de pesar menys de 40 MB.</string>
<string name="status_media_hidden_title">Multimèdia amagada</string>
<string name="status_content_show_less">Amaga</string>
<string name="action_logout_confirm">Estàs segur de tancar la sessió de %1$s\?</string>
<string name="action_logout_confirm">Estas segur de tancar la sessió de %1$s\?</string>
<string name="action_hide_reblogs">Amaga els impulsos</string>
<string name="action_show_reblogs">Mostra els impulsos</string>
<string name="action_delete_and_redraft">Elimina i reecririu</string>
@ -229,7 +229,7 @@
<string name="title_statuses_with_replies">Amb respostes</string>
<string name="action_emoji_keyboard">Teclat d\'emojis</string>
<string name="action_open_media_n">Obrir el media #%d</string>
<string name="action_open_as">Obrir com %s</string>
<string name="action_open_as">Obre com a %s</string>
<string name="downloading_media">Descarregant media</string>
<string name="status_sent_long">Resposta enviada correctament.</string>
<string name="label_quick_reply">Resposta …</string>
@ -418,7 +418,7 @@
<string name="failed_search">Cerca fallida</string>
<string name="poll_duration_1_hour">1 hora</string>
<string name="poll_duration_6_hours">6 hores</string>
<string name="edit_poll">Modificar</string>
<string name="edit_poll">Edita</string>
<string name="action_add_poll">Afegeix una enquesta</string>
<string name="create_poll_title">Enquesta</string>
<string name="poll_duration_5_min">5 minuts</string>
@ -471,5 +471,5 @@
<string name="action_unmute_domain">Deixar de silenciar %s</string>
<string name="action_mute_notifications_desc">Desactivar les notificacions per %s</string>
<string name="action_unmute_notifications_desc">Activar les notificacions per %s</string>
<string name="action_unmute_desc">Notificar %s</string>
<string name="action_unmute_desc">Deixar de silenciar %s</string>
</resources>

@ -4,7 +4,7 @@
<string name="error_network">Vyskytla se chyba sítě! Prosím zkontrolujte své připojení a zkuste to znovu!</string>
<string name="error_empty">Tohle nemůže být prázdné.</string>
<string name="error_invalid_domain">Neplatná doména zadána</string>
<string name="error_failed_app_registration">Autentikace s tímto serverem neuspělo.</string>
<string name="error_failed_app_registration">Autentizace s tímto serverem neuspěla.</string>
<string name="error_no_web_browser_found">Nelze najít webový prohlížeč k použití.</string>
<string name="error_authorization_unknown">Vyskytla se neidentifikovaná chyba autorizace.</string>
<string name="error_authorization_denied">Autorizace byla zamítnuta.</string>
@ -34,7 +34,7 @@
<string name="title_favourites">Oblíbené</string>
<string name="title_mutes">Skrytí uživatelé</string>
<string name="title_blocks">Blokovaní uživatelé</string>
<string name="title_follow_requests">Požadavky o sledování</string>
<string name="title_follow_requests">Žádosti o sledování</string>
<string name="title_edit_profile">Upravit váš profil</string>
<string name="title_saved_toot">Koncepty</string>
<string name="title_licenses">Licence</string>
@ -70,7 +70,7 @@
<string name="action_block">Blokovat</string>
<string name="action_unblock">Odblokovat</string>
<string name="action_hide_reblogs">Skrýt boosty</string>
<string name="action_show_reblogs">Zobrazit boosty</string>
<string name="action_show_reblogs">Zobrazi boosty</string>
<string name="action_report">Nahlásit</string>
<string name="action_delete">Smazat</string>
<string name="action_send">TOOTNOUT</string>
@ -78,12 +78,12 @@
<string name="action_retry">Zkusit znovu</string>
<string name="action_close">Zavřít</string>
<string name="action_view_profile">Profil</string>
<string name="action_view_preferences">Nastavení</string>
<string name="action_view_preferences">Předvolby</string>
<string name="action_view_account_preferences">Nastavení účtu</string>
<string name="action_view_favourites">Oblíbené</string>
<string name="action_view_favourites">Oblíbení</string>
<string name="action_view_mutes">Skrytí uživatelé</string>
<string name="action_view_blocks">Blokovaní uživatelé</string>
<string name="action_view_follow_requests">Požadavky o sledování</string>
<string name="action_view_follow_requests">Žádosti o sledování</string>
<string name="action_view_media">Média</string>
<string name="action_open_in_web">Otevřít v prohlížeči</string>
<string name="action_add_media">Přidat média</string>
@ -330,9 +330,9 @@
<string name="unpin_action">Odepnout</string>
<string name="pin_action">Připnout</string>
<plurals name="favs">
<item quantity="one">&lt;b&gt;%1$s&lt;/b&gt; oblíbení</item>
<item quantity="few">&lt;b&gt;%1$s&lt;/b&gt; oblíbení</item>
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; oblíbení</item>
<item quantity="one"><b>%1$s</b> oblíbení</item>
<item quantity="few"><b>%1$s</b> oblíbení</item>
<item quantity="other"><b>%1$s</b> oblíbení</item>
</plurals>
<plurals name="reblogs">
<item quantity="one"><b>%s</b> boost</item>
@ -356,8 +356,8 @@
<string name="description_status_favourited"> Oblíbený
</string>
<string name="description_visiblity_public">Veřejný</string>
<string name="description_visiblity_unlisted">Neuvedené</string>
<string name="description_visiblity_private">Sledující</string>
<string name="description_visiblity_unlisted">Neuvedený</string>
<string name="description_visiblity_private">Pro sledující</string>
<string name="description_visiblity_direct"> Přímý
</string>
<string name="hint_list_name">Název seznamu</string>
@ -371,13 +371,13 @@
<string name="notification_clear_text">Jste si jistý/á, že chcete trvale vymazat všechna vaše oznámení\?</string>
<string name="action_delete_and_redraft">Smazat a přepsat</string>
<string name="dialog_redraft_toot_warning">Smazat a přepsat tento toot\?</string>
<string name="poll_info_format"> <!-- 15 votes • 1 hour left --> %1$s • %2$s</string>
<string name="poll_info_format"> <!-- 15 hlasů • 1 hodin do konce --> %1$s • %2$s</string>
<plurals name="poll_info_votes">
<item quantity="one">%s hlas</item>
<item quantity="few">%s hlasy</item>
<item quantity="other">%s hlasů</item>
</plurals>
<string name="poll_info_time_relative">zbývá %s</string>
<string name="poll_info_time_relative">Zbývá %s</string>
<string name="poll_info_time_absolute">končí v %s</string>
<string name="poll_info_closed">uzavřena</string>
<string name="poll_vote">Hlasovat</string>
@ -424,7 +424,7 @@
<string name="report_remote_instance">Přeposlat na %s</string>
<string name="failed_report">Nahlášení selhalo</string>
<string name="failed_fetch_statuses">Stahování tootů neuspělo</string>
<string name="report_description_1">Nahlášení bude zasláno moderátorům vašeho serveru. Níže můžete uvést, proč tento účet nahlašujete:</string>
<string name="report_description_1">Nahlášení bude zasláno moderátorovi vašeho serveru. Níže můžete uvést, proč tento účet nahlašujete:</string>
<string name="report_description_remote_instance">Tento účet je z jiného serveru. Chcete na něj také poslat anonymizovanou kopii\?</string>
<string name="pref_title_show_notifications_filter">Zobrazit filtr oznámení</string>
<string name="create_poll_title">Anketa</string>
@ -454,7 +454,7 @@
<string name="notification_follow_request_description">Upozornění na žádosti o sledování</string>
<string name="notification_follow_request_name">Žádosti o sledování</string>
<string name="pref_main_nav_position_option_bottom">Dole</string>
<string name="pref_title_gradient_for_media">Ukázat barevné čtverečky místo skrytých médií</string>
<string name="pref_title_gradient_for_media">Zobrazení barevných prechodů pro skrytí citlivého obsahu</string>
<string name="dialog_mute_hide_notifications">Skrýt notifikace</string>
<string name="action_unmute_conversation">Zrušit ztlumení konverzace</string>
<string name="action_mute_conversation">Ztlumit konverzaci</string>
@ -466,7 +466,7 @@
<string name="warning_scheduling_interval">Mastodon neumožňuje pracovat s intervalem menším než 5 minut.</string>
<string name="no_scheduled_status">Zatím zde nemáte žádné naplánované statusy.</string>
<string name="no_saved_status">Zatím zde nejsou žádné koncepty.</string>
<string name="pref_title_enable_swipe_for_tabs">Možnost přetahování prstem pro přechod mezi kartami</string>
<string name="pref_title_enable_swipe_for_tabs">Možnost přetahování prstem pro přechod mezi kartam</string>
<string name="list">Seznam</string>
<string name="add_hashtag_title">Přidat hashtag</string>
<string name="description_status_bookmarked">Uloženo do Záložek</string>
@ -482,9 +482,9 @@
<string name="action_unmute_desc">Odkrýt %s</string>
<string name="dialog_mute_warning">Ztišit @%s\?</string>
<plurals name="poll_info_people">
<item quantity="one"></item>
<item quantity="few"></item>
<item quantity="other"></item>
<item quantity="one">%s osoba</item>
<item quantity="few">%s osoby</item>
<item quantity="other">%s osob</item>
</plurals>
<string name="notification_follow_request_format">%s požádal/a aby vás mohl/a sledovat</string>
<string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string>

@ -292,4 +292,12 @@
<string name="action_open_reblogged_by">Dangos hybiadau</string>
<string name="notification_follow_request_name">Dilyn ceisiadau</string>
<string name="action_bookmark">Nod tudalen</string>
<string name="action_edit">Golygu</string>
<string name="edit_poll">Golygu</string>
<string name="compose_shortcut_short_label">Creu</string>
<string name="description_visiblity_private">Dilynwyr</string>
<string name="description_visiblity_unlisted">Heb ei restru</string>
<string name="conversation_2_recipients">%1$s a %2$s</string>
<string name="filter_dialog_remove_button">Dileu</string>
<string name="description_visiblity_public">Cyhoeddus</string>
</resources>

@ -4,28 +4,28 @@
<string name="error_network">Ein Netzwerkfehler ist aufgetreten! Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut!</string>
<string name="error_empty">Dies darf nicht leer sein.</string>
<string name="error_invalid_domain">Ungültige Domain angegeben</string>
<string name="error_failed_app_registration">Authentifizieren mit dieser Instanz fehlgeschlagen.</string>
<string name="error_failed_app_registration">Diese App konnte sich auf dem Server nicht authentifizieren.</string>
<string name="error_no_web_browser_found">Kein Webbrowser gefunden.</string>
<string name="error_authorization_unknown">Ein undefinierbarer Autorisierungsfehler ist aufgetreten.</string>
<string name="error_authorization_denied">Autorisierung fehlgeschlagen.</string>
<string name="error_retrieving_oauth_token">Es konnte kein Anmeldungstoken abgerufen werden.</string>
<string name="error_compose_character_limit">Der Beitrag ist zu lang!</string>
<string name="error_image_upload_size">Die Datei muss kleiner als 8 MB sein.</string>
<string name="error_video_upload_size">Videodateien müssen kleiner als 40 MB sein.</string>
<string name="error_video_upload_size">Videodateien müssen kleiner als 40MB sein.</string>
<string name="error_media_upload_type">Dieser Dateityp darf nicht hochgeladen werden.</string>
<string name="error_media_upload_opening">Die Datei konnte nicht geöffnet werden.</string>
<string name="error_media_upload_permission">Leseberechtigung für die Mediendatei wird benötigt.</string>
<string name="error_media_download_permission">Schreibberechtigung für Mediendateien wird benötigt.</string>
<string name="error_media_upload_image_or_video">Bilder und Videos können nicht beide gleichzeitig an einen Beitrag angehängt werden.</string>
<string name="error_media_upload_sending">Die Mediendatei konnte nicht hochgeladen werden.</string>
<string name="error_sender_account_gone">Fehler beim Senden des Status.</string>
<string name="error_sender_account_gone">Fehler beim Senden des Beitrags.</string>
<string name="title_home">Start</string>
<string name="title_notifications">Benachrichtigungen</string>
<string name="title_public_local">Lokal</string>
<string name="title_public_federated">Föderiert</string>
<string name="title_direct_messages">Direktnachrichten</string>
<string name="title_tab_preferences">Tabs</string>
<string name="title_view_thread">Beitrag</string>
<string name="title_view_thread">Unterhaltung</string>
<string name="title_statuses">Beiträge</string>
<string name="title_statuses_with_replies">mit Antworten</string>
<string name="title_statuses_pinned">Angeheftet</string>
@ -109,7 +109,7 @@
<string name="action_links">Verlinkungen</string>
<string name="action_mentions">Erwähnungen</string>
<string name="action_hashtags">Hashtags</string>
<string name="action_open_reblogged_by">Zeige Boosts</string>
<string name="action_open_reblogged_by">Geteilte Beiträge anzeigen</string>
<string name="action_open_faved_by">Favoriten anzeigen</string>
<string name="title_hashtags_dialog">Hastags</string>
<string name="title_mentions_dialog">Erwähnungen</string>
@ -158,7 +158,7 @@
<string name="visibility_unlisted">Ungelistet: Nicht in der öffentlichen Timeline sichtbar</string>
<string name="visibility_private">Nur Folgende: Nur für Folgende sichtbar</string>
<string name="visibility_direct">Direkt: Nur für Erwähnte sichtbar</string>
<string name="pref_title_edit_notification_settings">Benachrichtigungseinstellungen</string>
<string name="pref_title_edit_notification_settings">Benachrichtigungen</string>
<string name="pref_title_notifications_enabled">Benachrichtigungen</string>
<string name="pref_title_notification_alerts">Benachrichtigungen</string>
<string name="pref_title_notification_alert_sound">Benachrichtige mit Sound</string>
@ -268,16 +268,16 @@
<string name="lock_account_label_description">Wer dir folgen möchte, muss um deine Erlaubnis bitten</string>
<string name="compose_save_draft">Entwurf speichern?</string>
<string name="send_toot_notification_title">Beitrag senden…</string>
<string name="send_toot_notification_error_title">Fehler beim Senden</string>
<string name="send_toot_notification_error_title">Fehler beim senden</string>
<string name="send_toot_notification_channel_name">Beiträge senden</string>
<string name="send_toot_notification_cancel_title">Senden abgebrochen</string>
<string name="send_toot_notification_saved_content">Eine Kopie des Beitrags wurde in deinen Entwürfen gespeichert</string>
<string name="action_compose_shortcut">Antworten</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>
<string name="download_fonts">Du musst diese Emoji-Sets zunächst herunterladen.</string>
<string name="performing_lookup_title">Nachschlagen…</string>
<string name="expand_collapse_all_statuses">Alle Beiträge aus-/einklappen</string>
<string name="action_open_toot">Beitrag öffnen</string>
@ -314,8 +314,7 @@
<string name="description_status_media_no_description_placeholder"> Keine Beschreibung </string>
<string name="description_status_favourited">Favorisiert </string>
<string name="description_visiblity_public">Öffentlich </string>
<string name="description_visiblity_private"> Follower
</string>
<string name="description_visiblity_private">Follower</string>
<string name="description_visiblity_direct">Direkt </string>
<string name="hint_list_name">Name auflisten</string>
<string name="download_media">Medien herunterladen</string>
@ -339,7 +338,7 @@
<string name="description_status_reblogged">Geteilt</string>
<string name="description_visiblity_unlisted">Ungelistet</string>
<string name="action_delete_and_redraft">Löschen und neu erstellen</string>
<string name="dialog_redraft_toot_warning">Bist du dir sicher, dass du diesen Beitrag löschen und neu erstellen möchtest\?</string>
<string name="dialog_redraft_toot_warning">Bist du dir sicher, dass du diesen Beitrag löschen und neu machen möchtest\?</string>
<string name="pref_title_notification_filter_poll">Umfragen beendet sind</string>
<string name="notification_poll_name">Umfragen</string>
<string name="notification_poll_description">Benachrichtigungen über beendete Umfragen</string>
@ -359,15 +358,15 @@
<string name="poll_info_time_absolute">endet um %s</string>
<string name="poll_info_closed">Geschlossen</string>
<string name="poll_vote">Abstimmen</string>
<string name="poll_ended_voted">Eine Umfrage in der du abgestimmt hast ist vorbei</string>
<string name="poll_ended_voted">Eine Umfrage, in der du abgestimmt hast, ist vorbei</string>
<string name="poll_ended_created">Eine Umfrage die du erstellt hast ist vorbei</string>
<plurals name="poll_timespan_days">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d Minute</item>
@ -405,7 +404,7 @@
<string name="poll_duration_3_days">3 Tage</string>
<string name="poll_duration_7_days">7 Tage</string>
<string name="edit_poll">Editieren</string>
<string name="about_tusky_version">Tusky %s</string>
<string name="about_tusky_version">test %s</string>
<string name="action_add_poll">Umfrage hinzufügen</string>
<string name="pref_title_alway_open_spoiler">Beiträge mit Inhaltswarnungen immer ausklappen</string>
<string name="description_poll">Umfrage mit den Möglichkeiten: %1$s, %2$s, %3$s, %4$s; %5$s</string>
@ -440,15 +439,15 @@
<string name="dialog_block_warning">Bist du dir sicher, dass du @%s blockieren möchtest\?</string>
<string name="action_unmute_conversation">Stummschaltung der Konversation aufheben</string>
<string name="action_mute_conversation">Konversation stummschalten</string>
<string name="notification_follow_request_format">"%s möchte dir folgen"</string>
<string name="notification_follow_request_format">%s möchte dir folgen</string>
<string name="hashtags">Hashtags</string>
<string name="add_hashtag_title">Hashtag hinzufügen</string>
<string name="pref_title_confirm_reblogs">Bestätigungsdialog vor dem Teilen eines Beitrags</string>
<string name="pref_title_show_cards_in_timelines">Linkvorschauen in Timelines anzeigen</string>
<string name="pref_title_enable_swipe_for_tabs">Wischgeste zum Wechseln zwischen Tabs</string>
<plurals name="poll_info_people">
<item quantity="one">%s Person</item>
<item quantity="other">%s Personen</item>
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="pref_title_gradient_for_media">Farbverlauf für versteckte Medien anzeigen</string>
<string name="abbreviated_seconds_ago">%dSek.</string>
@ -463,7 +462,7 @@
<string name="abbreviated_in_hours">in %dSt.</string>
<string name="pref_main_nav_position_option_bottom">Unten</string>
<string name="pref_main_nav_position_option_top">Oben</string>
<string name="action_unmute_domain">%s nicht mehr verstecken</string>
<string name="action_unmute_domain">Ton einschalten %s</string>
<plurals name="reblogs">
<item quantity="one"/>
<item quantity="other"/>

@ -434,7 +434,7 @@
<string name="add_poll_choice">Aldoni epekton</string>
<string name="poll_allow_multiple_choices">Multaj elektoj</string>
<string name="poll_new_choice_hint">Elekton %d</string>
<string name="edit_poll">Redaktigi</string>
<string name="edit_poll">Redakti</string>
<string name="title_bookmarks">Legosignoj</string>
<string name="title_scheduled_toot">Planitaj mesaĝoj</string>
<string name="action_bookmark">Aldoni al la legosignoj</string>

@ -3,7 +3,7 @@
<string name="error_generic">Ha ocurrido un error.</string>
<string name="error_network">¡Se ha producido un error de red! ¡Por favor, comprueba tu conexión e inténtalo de nuevo!</string>
<string name="error_empty">Este campo no puede estar vacío.</string>
<string name="error_invalid_domain">Nombre de dominio no válido</string>
<string name="error_invalid_domain">Nombre de dominio incorrecto</string>
<string name="error_failed_app_registration">Fallo de autenticación con esta instancia.</string>
<string name="error_no_web_browser_found">No se ha encontrado ningún navegador web.</string>
<string name="error_authorization_unknown">Ocurrió un error de autorización no identificado.</string>
@ -68,7 +68,7 @@
<string name="action_block">Bloquear</string>
<string name="action_unblock">Desbloquear</string>
<string name="action_hide_reblogs">Ocultar impulsos</string>
<string name="action_show_reblogs">Mostrar impulsos</string>
<string name="action_show_reblogs">Mostrar compartidos</string>
<string name="action_report">Reportar</string>
<string name="action_delete">Borrar</string>
<string name="action_send">Enviar</string>
@ -148,7 +148,7 @@
<string name="visibility_private">Privado: Sólo visible para seguidores</string>
<string name="visibility_direct">Directo: Sólo visible para cuentas mencionadas</string>
<string name="pref_title_edit_notification_settings">Editar notificaciones</string>
<string name="pref_title_notifications_enabled">Notificaciones</string>
<string name="pref_title_notifications_enabled">Editar notificaciones</string>
<string name="pref_title_notification_alerts">Alertas</string>
<string name="pref_title_notification_alert_sound">Notificar con sonido</string>
<string name="pref_title_notification_alert_vibrate">Notificar con vibración</string>
@ -385,7 +385,7 @@
<string name="notifications_clear">Limpiar</string>
<string name="notifications_apply_filter">Filtro</string>
<string name="compose_shortcut_long_label">Componer toot</string>
<string name="compose_shortcut_short_label">Componer</string>
<string name="compose_shortcut_short_label">Redactar</string>
<string name="notification_clear_text">¿Estás seguro de que quieres eliminar permanentemente todas tus notificaciones\?</string>
<string name="compose_preview_image_description">Acciones para la imagen %s</string>
<string name="poll_info_time_relative">%s restante</string>
@ -393,7 +393,7 @@
<string name="poll_ended_voted">Una encuesta en la que has votado ha terminado</string>
<string name="poll_ended_created">Una encuesta que has creado ha terminado</string>
<string name="action_open_reblogger">Abrir autor del impulso</string>
<string name="action_open_reblogged_by">Mostrar impulsos</string>
<string name="action_open_reblogged_by">Mostrar compartidos</string>
<string name="title_domain_mutes">Dominios ocultos</string>
<string name="action_view_domain_mutes">Dominios ocultos</string>
<string name="action_mute_domain">Silenciar %s</string>

@ -136,7 +136,7 @@
<string name="visibility_unlisted">Ezkutukoa: Ez erakutsi istorio publikoetan</string>
<string name="visibility_private">Pribatua: Jarraitzaileentzat soilik ikusgai</string>
<string name="visibility_direct">Zuzena: Aipatutako kontuentzat bakarrik ikusgai</string>
<string name="pref_title_edit_notification_settings">Editatu jakinarazpenak</string>
<string name="pref_title_edit_notification_settings">Jakinarazpenak</string>
<string name="pref_title_notifications_enabled">Jakinarazpenak</string>
<string name="pref_title_notification_alerts">Alertak</string>
<string name="pref_title_notification_alert_sound">Soinuarekin jakinarazi</string>
@ -148,12 +148,12 @@
<string name="pref_title_notification_filter_reblogs">Bultzatzen naute</string>
<string name="pref_title_notification_filter_favourites">Nire argitarapenak gustokoak izan dira</string>
<string name="pref_title_appearance_settings">Interfazea</string>
<string name="pref_title_app_theme">Aplikazioaren gaia</string>
<string name="pref_title_app_theme">Gaia</string>
<string name="pref_title_timelines">Denbora-lerroak</string>
<string name="app_them_dark">Iluna</string>
<string name="app_theme_light">Argia</string>
<string name="app_theme_black">Beltza</string>
<string name="app_theme_auto">Automatikoa iluntzean</string>
<string name="app_theme_auto">Automatikoa</string>
<string name="pref_title_browser_settings">Nabigatzailea</string>
<string name="pref_title_custom_tabs">Chromeko fitxa pertsonalizatuak erabili</string>
<string name="pref_title_hide_follow_button">Ezkutatu idazteko botoia mugitzean</string>
@ -172,7 +172,7 @@
<string name="pref_publishing">Argitaratzeak (zerbitzariarekin sinkronizatua)</string>
<string name="pref_failed_to_sync">Aukerak sinkronizatzean akatsa</string>
<string name="post_privacy_public">Publikoa</string>
<string name="post_privacy_unlisted">Zerrendatu gabea</string>
<string name="post_privacy_unlisted">Zerrendagabetuta</string>
<string name="post_privacy_followers_only">Jarraitzaileak soilik</string>
<string name="pref_status_text_size">Status testuaren tamaina</string>
<string name="status_text_size_smallest">Oso txikia</string>
@ -396,8 +396,8 @@
<item quantity="other">%d egun</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">Ordu %d</item>
<item quantity="other">%d ordu</item>
<item quantity="one"></item>
<item quantity="other"></item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">Minutu %d</item>

@ -14,7 +14,7 @@
<string name="error_media_upload_type">این نوع از پرونده نمیتواند بارگذاری شود.</string>
<string name="error_media_upload_opening">پرونده باز نشد.</string>
<string name="error_media_upload_permission">مجوز برای خواندن رسانه نیاز است.</string>
<string name="error_media_download_permission">اجازه ذخیره رسانه نیاز است.</string>
<string name="error_media_download_permission">اجازه ذخیره رسانه نیاز است</string>
<string name="error_media_upload_image_or_video">تصاویر و فیلمها هر دو نمیتوانند به یک وضعیت ضمیمه شوند.</string>
<string name="error_media_upload_sending">بارگذاری ناموفق بود.</string>
<string name="error_sender_account_gone">خطا در ارسال بوق.</string>
@ -54,7 +54,7 @@
<string name="action_favourite">مورد علاقه</string>
<string name="action_more">بیشتر</string>
<string name="action_compose">ایجاد</string>
<string name="action_login">با ماستودون وارد شو</string>
<string name="action_login">ورود با ماستودون</string>
<string name="action_logout">خروج</string>
<string name="action_logout_confirm">آیا از خارج شدن از این حساب %1s اطمینان دارید؟</string>
<string name="action_follow">دنبال کن</string>
@ -79,7 +79,7 @@
<string name="action_view_media">رسانه</string>
<string name="action_open_in_web">گشودن در مرورگر</string>
<string name="action_add_media">افزودن رسانه</string>
<string name="action_photo_take">گرفتن عکس</string>
<string name="action_photo_take">عکس بگیر</string>
<string name="action_share">همرسانی</string>
<string name="action_mute">خموش</string>
<string name="action_unmute">گویا</string>
@ -336,7 +336,7 @@
<string name="hint_search_people_list">جستوجو برای دنبالشوندگانتان</string>
<string name="action_add_to_list">افزودن حساب به فهرست</string>
<string name="action_remove_from_list">حذف حساب از فهرست</string>
<string name="caption_notoemoji">مجموعه شکلکهای جاری گوگل</string>
<string name="caption_notoemoji">مجموعهٔ اموجی کنونی گوگل</string>
<string name="license_cc_by_4">نگارش ۴ CC-BY</string>
<string name="license_cc_by_sa_4">نگارش ۴ CC-BY-SA</string>
<plurals name="favs">
@ -360,7 +360,7 @@
<string name="description_status_favourited">پسندیده</string>
<string name="description_visiblity_public">عمومی</string>
<string name="description_visiblity_unlisted">فهرستنشده</string>
<string name="description_visiblity_private">پیگیران</string>
<string name="description_visiblity_private">پیروان</string>
<string name="description_visiblity_direct">مستقیم</string>
<string name="description_poll">نظرسنجی با انتخابها: %1$s، %2$s، %3$s، %4$s؛ %5$s</string>
<string name="hint_list_name">نام فهرست</string>
@ -402,7 +402,7 @@
<string name="button_back">بازگشت</string>
<string name="button_done">اتمام</string>
<string name="report_sent_success">\@%s با موفّقیت گزارش شد</string>
<string name="hint_additional_info">نظرات اضافی</string>
<string name="hint_additional_info">نظرات بیشتر</string>
<string name="report_remote_instance">هدایت به %s</string>
<string name="failed_report">شکست در گزارش</string>
<string name="failed_fetch_statuses">شکست در واکشی وضعیتها</string>

@ -249,13 +249,13 @@
<!--These are for timestamps on statuses. For example: "16s" or "2d"-->
<string name="abbreviated_in_years">en %da</string>
<string name="abbreviated_in_days">en %dj</string>
<string name="abbreviated_in_hours">en %dh</string>
<string name="abbreviated_in_hours">en %dh</string>
<string name="abbreviated_in_minutes">en %dm</string>
<string name="abbreviated_in_seconds">en %ds</string>
<string name="abbreviated_in_seconds">en %ds</string>
<string name="abbreviated_years_ago">%da</string>
<string name="abbreviated_days_ago">%dj</string>
<string name="abbreviated_hours_ago">%dh</string>
<string name="abbreviated_minutes_ago">%dmin</string>
<string name="abbreviated_days_ago">%dj</string>
<string name="abbreviated_hours_ago">%dh</string>
<string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</string>
<string name="follows_you">Vous suit</string>
<string name="pref_title_alway_show_sensitive_media">Toujours afficher le contenu sensible</string>

@ -0,0 +1,490 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="status_content_show_more">Leathnaigh</string>
<string name="status_content_warning_show_less">Taispeáin Níos Lú</string>
<string name="status_content_warning_show_more">Taispeáin Níos Mó</string>
<string name="status_sensitive_media_directions">Cliceáil chun amharc</string>
<string name="status_media_hidden_title">Meáin i bhfolach</string>
<string name="status_sensitive_media_title">Ábhar íogair</string>
<string name="status_boosted_format">threisigh %s</string>
<string name="title_licenses">Ceadúnais</string>
<string name="title_scheduled_toot">Tútanna sceidealta</string>
<string name="title_edit_profile">Cuir do phróifíl in eagar</string>
<string name="title_follow_requests">Lean Iarrataí</string>
<string name="title_domain_mutes">Fearainn i bhfolach</string>
<string name="title_blocks">Úsáideoirí blocáilte</string>
<string name="title_mutes">Úsáideoirí fuaim</string>
<string name="title_bookmarks">Leabharmharcanna</string>
<string name="title_followers">Leanúna</string>
<string name="title_follows">Leanúna</string>
<string name="title_statuses_pinned">Greamaithe</string>
<string name="title_statuses_with_replies">Le freagraí</string>
<string name="title_view_thread">Tút</string>
<string name="title_direct_messages">Teachtaireachtaí Díreacha</string>
<string name="title_public_federated">Cónaidhme</string>
<string name="title_public_local">Áitiúil</string>
<string name="title_notifications">Fógraí</string>
<string name="title_home">Baile</string>
<string name="error_sender_account_gone">Earráid agus an toot á sheoladh.</string>
<string name="error_media_upload_sending">Theip ar an uaslódáil.</string>
<string name="error_media_upload_image_or_video">Ní féidir íomhánna agus físeáin a cheangal leis an stádas céanna.</string>
<string name="error_media_download_permission">Teastaíonn cead chun na meáin a stóráil.</string>
<string name="error_media_upload_permission">Teastaíonn cead chun na meáin a léamh.</string>
<string name="error_media_upload_opening">Ní fhéadfaí an comhad sin a oscailt.</string>
<string name="pref_title_hide_follow_button">Folaigh an cnaipe cum agus tú ag scrollaigh</string>
<string name="pref_title_custom_tabs">Úsáid Chrome Custom Tabs</string>
<string name="pref_title_browser_settings">Brabhsálaí</string>
<string name="app_theme_system">Úsáid Dearadh Córais</string>
<string name="app_theme_auto">Uathoibríoch ag luí na gréine</string>
<string name="app_theme_black">Dubh</string>
<string name="app_theme_light">Éadrom</string>
<string name="app_them_dark">Dorcha</string>
<string name="pref_title_timeline_filters">Scagairí</string>
<string name="pref_title_timelines">Amlínte</string>
<string name="pref_title_app_theme">Téama an Aip</string>
<string name="pref_title_appearance_settings">Dealramh</string>
<string name="pref_title_notification_filter_poll">tá deireadh leis na pobalbhreitheanna</string>
<string name="pref_title_notification_filter_favourites">ainmnítear mo phoist</string>
<string name="pref_title_notification_filter_reblogs">treisítear mo tútanna</string>
<string name="pref_title_notification_filter_follow_requests">lean iarrtar</string>
<string name="pref_title_notification_filter_follows">lean</string>
<string name="pref_title_notification_filter_mentions">luaigh</string>
<string name="pref_title_notification_filters">Cuir in iúl dom cathain</string>
<string name="pref_title_notification_alert_light">Fógra le solas</string>
<string name="pref_title_notification_alert_vibrate">Cuir in iúl le tonnchrith</string>
<string name="pref_title_notification_alert_sound">Fógra le fuaim</string>
<string name="pref_title_notification_alerts">Foláirimh</string>
<string name="pref_title_notifications_enabled">Fógraí</string>
<string name="pref_title_edit_notification_settings">Fógraí</string>
<string name="visibility_direct">Díreach: Post chuig úsáideoirí luaite amháin</string>
<string name="visibility_private">Leantóirí-Amháin: Postáil do leanúna amháin</string>
<string name="visibility_unlisted">Neamhliostaithe: Ná taispeáin in amlínte poiblí</string>
<string name="visibility_public">Poiblí: Post chuig amlínte poiblí</string>
<string name="dialog_mute_hide_notifications">Folaigh fógraí</string>
<string name="dialog_block_warning">Bloc @%s\?</string>
<string name="mute_domain_warning_dialog_ok">Folaigh an fearann iomlán</string>
<string name="mute_domain_warning">An bhfuil tú cinnte gur mhaith leat gach %s a bhac\? Ní fheicfidh tú ábhar ón bhfearann sin in aon amlínte poiblí ná i d’fhógraí. Bainfear do leanúna ón bhfearann sin.</string>
<string name="dialog_redraft_toot_warning">An tút seo a scriosadh agus a dhréachtú\?</string>
<string name="dialog_delete_toot_warning">Scrios an tút seo\?</string>
<string name="dialog_unfollow_warning">An cuntas seo a scaoileadh\?</string>
<string name="dialog_message_cancel_follow_request">An iarraidh seo a leanas a chúlghairm\?</string>
<string name="dialog_download_image">Íoslódáil</string>
<string name="dialog_message_uploading_media">Uaslódáil…</string>
<string name="dialog_title_finishing_media_upload">Uaslódáil Meáin Críochnaithe</string>
<string name="login_connection">Ag nascadh…</string>
<string name="label_header">Ceanntásc</string>
<string name="label_quick_reply">Freagra…</string>
<string name="search_no_results">Gan torthaí</string>
<string name="hint_search">Cuardaigh…</string>
<string name="hint_note">Bith</string>
<string name="hint_display_name">Ainm taispeána</string>
<string name="hint_content_warning">Rabhadh ábhair</string>
<string name="hint_compose">Cad atá ag tarlú\?</string>
<string name="hint_domain">Cén cás\?</string>
<string name="status_sent_long">Cuireadh an freagra go rathúil.</string>
<string name="status_sent">Seolta!</string>
<string name="confirmation_domain_unmuted">%s neamhcheangailte</string>
<string name="confirmation_unmuted">Úsáideoir gan trácht</string>
<string name="confirmation_unblocked">Úsáideoir gan bhac</string>
<string name="confirmation_reported">Seolta!</string>
<string name="send_media_to">Comhroinn na meáin le…</string>
<string name="send_status_content_to">Comhroinn tút chuig…</string>
<string name="send_status_link_to">Comhroinn URL tút chuig…</string>
<string name="downloading_media">Meáin íoslódála</string>
<string name="download_media">Íoslódáil na meáin</string>
<string name="action_share_as">Comhroinn mar …</string>
<string name="action_open_as">Oscail mar %s</string>
<string name="action_copy_link">Cóipeáil an nasc</string>
<string name="download_image">Íoslódáil %1$s</string>
<string name="action_open_media_n">Meáin oscailte #%d</string>
<string name="title_links_dialog">Naisc Ghréasáin</string>
<string name="action_open_faved_by">Taispeáin ainmniúcháin</string>
<string name="action_open_reblogged_by">Taispeáin borradh</string>
<string name="action_open_reblogger">Údar borradh oscailte</string>
<string name="action_mentions">Buaicphointí</string>
<string name="action_links">Naisc ghréasáin</string>
<string name="action_add_tab">Cuir Tab leis</string>
<string name="action_schedule_toot">Tút a sceidealú</string>
<string name="action_emoji_keyboard">Méarchlár Emoji</string>
<string name="action_content_warning">Rabhadh ábhair</string>
<string name="action_toggle_visibility">Infheictheacht tút</string>
<string name="action_access_scheduled_toot">Tútanna sceidealta</string>
<string name="action_access_saved_toot">Dréachtaí</string>
<string name="action_reject">Diúltaigh</string>
<string name="action_accept">Glac</string>
<string name="action_undo">Cealaigh</string>
<string name="action_edit_own_profile">Cuir in Eagar</string>
<string name="action_save">Sábháil</string>
<string name="action_open_drawer">Tarraiceán a oscailt</string>
<string name="action_hide_media">Folaigh na meáin</string>
<string name="action_mention">Luaigh</string>
<string name="action_mute_notifications_desc">Fógraí tost ó %s</string>
<string name="action_unmute_notifications_desc">Fógraí neamhshábháilteachta ó %s</string>
<string name="action_unmute">Unmute</string>
<string name="action_mute">Tost</string>
<string name="action_share">Comhroinn</string>
<string name="action_photo_take">Tóg pictiúr</string>
<string name="action_add_poll">Cuir vótaíocht leis</string>
<string name="action_add_media">Cuir meáin leis</string>
<string name="action_open_in_web">Oscail sa bhrabhsálaí</string>
<string name="action_view_media">Meáin</string>
<string name="action_view_follow_requests">Lean Iarrataí</string>
<string name="action_view_domain_mutes">Fearainn i bhfolach</string>
<string name="action_view_blocks">Úsáideoirí blocáilte</string>
<string name="action_view_mutes">Úsáideoirí fuaim</string>
<string name="action_view_bookmarks">Leabharmharcanna</string>
<string name="action_view_favourites">Ainmniúcháin</string>
<string name="action_view_profile">Próifíl</string>
<string name="action_close">Dún</string>
<string name="action_retry">Atriail</string>
<string name="action_send_public">TÚT!</string>
<string name="action_send">TÚT</string>
<string name="action_delete_and_redraft">Scrios agus athdhréachtú</string>
<string name="action_delete">Scrios</string>
<string name="action_edit">Cuir in Eagar</string>
<string name="action_report">Inis</string>
<string name="action_show_reblogs">Taispeáin borradh</string>
<string name="action_hide_reblogs">Folaigh borradh</string>
<string name="action_unblock">Ná bac</string>
<string name="action_block">Bac</string>
<string name="action_unfollow">Stop ag leanúint</string>
<string name="action_follow">Lean</string>
<string name="action_logout_confirm">An bhfuil tú cinnte gur mhaith leat logáil amach as an gcuntas %1$s\?</string>
<string name="action_compose">Cum</string>
<string name="action_more">Níos mó</string>
<string name="action_unfavourite">Bain ainmniúchán</string>
<string name="action_bookmark">Leabharmharc</string>
<string name="action_favourite">Ainmnigh</string>
<string name="error_media_upload_type">Ní féidir an cineál comhaid sin a uaslódáil.</string>
<string name="error_audio_upload_size">Caithfidh comhaid fuaime a bheith níos lú ná 40MB.</string>
<string name="error_video_upload_size">Caithfidh comhaid físe a bheith níos lú ná 40MB.</string>
<string name="error_image_upload_size">Caithfidh an comhad a bheith níos lú ná 8MB.</string>
<string name="error_compose_character_limit">Tá an stádas ró-fhada!</string>
<string name="error_retrieving_oauth_token">Theip ar chomhartha logála isteach a fháil.</string>
<string name="error_authorization_denied">Diúltaíodh údarú.</string>
<string name="error_authorization_unknown">Tharla earráid údaraithe neamhaitheanta.</string>
<string name="error_no_web_browser_found">Níorbh fhéidir brabhsálaí gréasáin a aimsiú le húsáid.</string>
<string name="error_invalid_domain">Fearann neamhbhailí iontráilte</string>
<string name="error_empty">Ní féidir leis seo a bheith folamh.</string>
<string name="error_network">Tharla earráid líonra! Seiceáil do nasc agus bain triail eile as!</string>
<string name="error_generic">Tharla earráid.</string>
<string name="title_lists">Liostaí</string>
<string name="action_lists">Liostaí</string>
<string name="about_title_activity">Faoi</string>
<string name="action_reset_schedule">Athshocraigh</string>
<string name="action_search">Cuardaigh</string>
<string name="action_edit_profile">Cuir próifíl in eagar</string>
<string name="action_view_account_preferences">Roghanna Cuntais</string>
<string name="action_view_preferences">Sainroghanna</string>
<string name="action_logout">Logáil Amach</string>
<string name="title_saved_toot">Dréachtaí</string>
<string name="title_favourites">Roghaí</string>
<string name="error_failed_app_registration">Theip ar fhíordheimhniú leis an gcás sin.</string>
<string name="link_whats_an_instance">Cad is sampla ann\?</string>
<string name="action_login">Logáil isteach le Mastodon</string>
<string name="status_media_images">Íomhánna</string>
<string name="status_share_link">Comhroinn nasc le tút</string>
<string name="status_share_content">Comhroinn ábhar na tút</string>
<string name="about_tusky_account">Próifíl Tusky</string>
<string name="about_tusky_license">Is bogearraí foinse oscailte agus saor in aisce é Tusky. Tá sé ceadúnaithe faoi Leagan 3. Ceadúnas Poiblí Ginearálta GNU 3. Is féidir leat an ceadúnas a fheiceáil anseo: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_powered_by_tusky">Cumhachtaithe ag Tusky</string>
<string name="about_tusky_version">Tusky %s</string>
<string name="description_account_locked">Cuntas faoi Ghlas</string>
<string name="notification_title_summary">%d idirghníomhaíochtaí nua</string>
<string name="notification_summary_small">%1$s agus %2$s</string>
<string name="notification_summary_medium">%1$s, %2$s, agus %3$s</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s agus %4$d cinn eile</string>
<string name="notification_mention_format">Luaigh %s tú</string>
<string name="notification_poll_description">Fógraí faoi pobalbhreitheanna a bhfuil deireadh leo</string>
<string name="notification_poll_name">Vótaí</string>
<string name="notification_favourite_description">Fógraí nuair a mharcáiltear do tútanna mar an ceann is fearr leat</string>
<string name="notification_favourite_name">Rogha</string>
<string name="notification_boost_description">Fógraí nuair a dhéantar borradh faoi do tútanna</string>
<string name="notification_boost_name">Borradh</string>
<string name="notification_follow_request_description">Fógraí faoi iarratais a leanúint</string>
<string name="notification_follow_request_name">Lean Iarrataí</string>
<string name="notification_follow_description">Fógraí faoi leanúna nua</string>
<string name="notification_follow_name">Leantóirí Nua</string>
<string name="notification_mention_descriptions">Fógraí faoi luanna nua</string>
<string name="notification_mention_name">Tagairtí Nua</string>
<string name="status_text_size_largest">Is mó</string>
<string name="status_text_size_large">Móra</string>
<string name="status_text_size_medium">Mheán</string>
<string name="status_text_size_small">Beag</string>
<string name="status_text_size_smallest">Lúide</string>
<string name="pref_status_text_size">Méid an téacs stádais</string>
<string name="post_privacy_followers_only">Leantóirí-amháin</string>
<string name="post_privacy_unlisted">Neamhliostaithe</string>
<string name="post_privacy_public">Poiblí</string>
<string name="pref_main_nav_position_option_bottom">Bun</string>
<string name="pref_main_nav_position_option_top">Barr</string>
<string name="pref_main_nav_position">Príomhshuíomh nascleanúna</string>
<string name="pref_failed_to_sync">Theip ar shocruithe a sync</string>
<string name="pref_publishing">Foilsitheoireacht (synced leis an bhfreastalaí)</string>
<string name="pref_default_media_sensitivity">Déan na meáin a mharcáil i gcónaí mar íogaire</string>
<string name="pref_default_post_privacy">Príobháideacht réamhshocraithe tút</string>
<string name="pref_title_http_proxy_port">Port seachfhreastalaí HTTP</string>
<string name="pref_title_http_proxy_server">Freastalaí seachfhreastalaí HTTP</string>
<string name="pref_title_http_proxy_enable">Cumasaigh seachfhreastalaí HTTP</string>
<string name="pref_title_http_proxy_settings">Seachfhreastalaí HTTP</string>
<string name="pref_title_proxy_settings">Seachfhreastalaí</string>
<string name="pref_title_show_media_preview">Íoslódáil réamhamharcanna na meán</string>
<string name="pref_title_show_replies">Taispeáin freagraí</string>
<string name="pref_title_show_boosts">Taispeáin borradh</string>
<string name="pref_title_status_filter">Scagadh amlíne</string>
<string name="pref_title_gradient_for_media">Taispeáin grádáin ildaite do na meáin i bhfolach</string>
<string name="pref_title_animate_gif_avatars">Beochan abhatár GIF</string>
<string name="pref_title_bot_overlay">Taispeáin táscaire do róbónna</string>
<string name="pref_title_language">Teanga</string>
<string name="label_avatar">Abhatár</string>
<string name="title_mentions_dialog">Tráchtanna</string>
<string name="action_reblog">Borradh</string>
<string name="action_unreblog">Bain borradh</string>
<string name="action_reply">Freagra</string>
<string name="action_quick_reply">Freagra Tapa</string>
<string name="report_comment_hint">Tuairimí Breise\?</string>
<string name="report_username_format">Tuairiscigh @%s</string>
<string name="notification_follow_request_format">D’iarr %s tú a leanúint</string>
<string name="notification_follow_format">lean %s thú</string>
<string name="notification_reblog_format">Chuir %s borradh faoi do tút</string>
<string name="footer_empty">Níl aon rud anseo. Tarraingt anuas chun athnuachan a dhéanamh!</string>
<string name="message_empty">Níl aon rud anseo.</string>
<string name="status_content_show_less">Dlúth</string>
<string name="filter_dialog_whole_word_description">Nuair atá an eochairfhocal nó an frása alfa-uimhriúil amháin, ní chuirfear i bhfeidhm é ach má oireann sé don fhocal iomlán</string>
<string name="title_statuses">Tútanna</string>
<string name="notification_favourite_format">Bhí %s i bhfabhar do tút</string>
<string name="action_unmute_desc">Unmute %s</string>
<string name="action_mute_conversation">Comhrá tost</string>
<string name="action_hashtags">Clibeanna hash</string>
<string name="title_hashtags_dialog">Hashtags</string>
<string name="dialog_whats_an_instance">Is féidir seoladh nó fearann aon cháis a iontráil anseo, mar shampla mastodon.social, icosahedron.website, social.tchncs.de, agus <a href="https://instances.social"> níos mó! </a>
\n
\nMura bhfuil cuntas agat fós, is féidir leat ainm an cháis ar mhaith leat a bheith páirteach ann agus cuntas a chruthú ann.
\n
\nIs áit amháin é sampla ina ndéantar do chuntas a óstáil, ach is féidir leat cumarsáid a dhéanamh go héasca le daoine eile agus iad a leanúint ar chásanna eile mar a bheadh tú ar an suíomh céanna.
\n
\nIs féidir tuilleadh faisnéise a fháil ag <a href="https://joinmastodon.org"> joinmastodon.org </a>. </string>
<string name="about_project_site">Suíomh Gréasáin an tionscadail:
\n https://tusky.app</string>
<string name="about_bug_feature_request_site">Tuarascálacha ar fhabhtanna &amp; iarratais ar ghnéithe:
\n https://github.com/tuskyapp/Tusky/issues</string>
<string name="status_media_video">Físeán</string>
<string name="state_follow_requested">Lean iarrtha</string>
<string name="follows_you">Leanann tú</string>
<string name="pref_title_alway_show_sensitive_media">Taispeáin ábhar íogair i gcónaí</string>
<string name="filter_dialog_update_button">Nuashonrú</string>
<string name="filter_dialog_whole_word">Focal iomlán</string>
<string name="filter_add_description">Frása le scagadh</string>
<string name="add_account_name">Cuir Cuntas leis</string>
<string name="pref_title_alway_open_spoiler">Leathnaigh i gcónaí tútanna atá marcáilte le rabhaidh ábhair</string>
<string name="title_media">Meáin</string>
<string name="replying_to">Ag freagairt do @%s</string>
<string name="load_more_placeholder_text">luchtú níos mó</string>
<string name="pref_title_thread_filter_keywords">Comhráite</string>
<string name="filter_addition_dialog_title">Cuir scagaire leis</string>
<string name="filter_edit_dialog_title">Cuir scagaire in eagar</string>
<string name="filter_dialog_remove_button">Bain</string>
<string name="pref_title_public_filter_keywords">Amlínte poiblí</string>
<string name="expand_collapse_all_statuses">Gach stádas a leathnú/a thit amach</string>
<string name="restart_emoji">Beidh ort Tusky a atosú chun na hathruithe seo a chur i bhfeidhm</string>
<string name="caption_notoemoji">Sraith emoji reatha Google</string>
<string name="license_description">Tá cód agus sócmhainní ó na tionscadail foinse oscailte seo a leanas i Tusky:</string>
<string name="label_remote_account">Féadfaidh an fhaisnéis thíos próifíl an úsáideora a léiriú go neamhiomlán. Brúigh chun próifíl iomlán a oscailt sa bhrabhsálaí.</string>
<string name="max_tab_number_reached">uasmhéid de chluaisíní %1$d sroichte</string>
<string name="description_poll">Vótaíocht le roghanna: %1$s, %2$s, %3$s, %4$s; %5$s</string>
<string name="list">Liosta</string>
<string name="compose_shortcut_long_label">Cumadh Tút</string>
<string name="notification_clear_text">An bhfuil tú cinnte gur mhaith leat do chuid fógraí go léir a ghlanadh go buan\?</string>
<string name="poll_ended_created">Tá deireadh le vótaíocht a chruthaigh tú</string>
<plurals name="poll_timespan_minutes">
<item quantity="one">$d nóiméad</item>
<item quantity="two">$d nóiméad</item>
<item quantity="few">$d nóiméad</item>
<item quantity="many">$d nóiméad</item>
<item quantity="other">$d nóiméad</item>
</plurals>
<string name="failed_fetch_statuses">Theip ar stádas a fháil</string>
<string name="report_description_1">Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos:</string>
<string name="add_account_description">Cuir Cuntas Mastodon nua leis</string>
<string name="title_list_timeline">Liostaigh amlíne</string>
<string name="error_create_list">Níorbh fhéidir liosta a chruthú</string>
<string name="error_rename_list">Níorbh fhéidir an liosta a athainmniú</string>
<string name="error_delete_list">Níorbh fhéidir an liosta a scriosadh</string>
<string name="action_create_list">Cruthaigh liosta</string>
<string name="action_rename_list">Athainmnigh an liosta</string>
<string name="action_delete_list">Scrios an liosta</string>
<string name="action_edit_list">Cuir an liosta in eagar</string>
<string name="hint_search_people_list">Cuardaigh daoine a leanann tú</string>
<string name="action_add_to_list">Cuir cuntas leis an liosta</string>
<string name="action_remove_from_list">Bain cuntas ón liosta</string>
<string name="compose_active_account_description">Postáil le cuntas %1$s</string>
<string name="error_failed_set_caption">Theip ar an bhfotheideal a shocrú</string>
<string name="hint_describe_for_visually_impaired">Déan cur síos ar dhaoine lagamhairc
\n(teorainn carachtar %d)</string>
<string name="action_set_caption">Socraigh fotheideal</string>
<string name="action_remove">Bain</string>
<string name="lock_account_label">Cuntas glasála</string>
<string name="lock_account_label_description">Éilíonn ort leanúna a cheadú de láimh</string>
<string name="compose_save_draft">Sábháil dréacht\?</string>
<string name="send_toot_notification_title">Tút a sheoladh…</string>
<string name="send_toot_notification_error_title">Earráid agus an tút á sheoladh</string>
<string name="send_toot_notification_channel_name">Tútanna a sheoladh</string>
<string name="send_toot_notification_cancel_title">Seoladh curtha ar ceal</string>
<string name="send_toot_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>
<string name="performing_lookup_title">Amharc taibhithe…</string>
<string name="action_open_toot">Oscail tút</string>
<string name="restart_required">Atosú aip de dhíth</string>
<string name="later">Níos déanaí</string>
<string name="restart">Atosaigh</string>
<string name="caption_systememoji">Tacar emoji réamhshocraithe do ghléas</string>
<string name="caption_blobmoji">Na emojis Blob atá ar eolas ó Android 4.4-7.1</string>
<string name="caption_twemoji">Tacar emoji caighdeánach Mastodon</string>
<string name="download_failed">Theip ar íoslódáil</string>
<string name="profile_badge_bot_text">Bot</string>
<string name="account_moved_description">Tá %1$s tar éis bogadh go:</string>
<string name="reblog_private">Treisiú leis an lucht féachana bunaidh</string>
<string name="license_apache_2">Ceadúnaithe faoin gCeadúnas Apache (cóip thíos)</string>
<string name="profile_metadata_label">Meiteashonraí próifíle</string>
<string name="profile_metadata_add">cuir sonraí leis</string>
<string name="profile_metadata_label_label">Lipéad</string>
<string name="profile_metadata_content_label">Ábhar</string>
<string name="pref_title_absolute_time">Úsáid am iomlán</string>
<string name="pin_action">Bioráin</string>
<plurals name="favs">
<item quantity="one"><b>%1$s </b> Ainmniú</item>
<item quantity="two"><b>%1$s</b> Ainmniúchán</item>
<item quantity="few"><b>%1$s</b> Ainmniúchán</item>
<item quantity="many"><b>%1$s</b> Ainmniúchán</item>
<item quantity="other"><b>%1$s</b> Ainmniúchán</item>
</plurals>
<plurals name="reblogs">
<item quantity="one"><b>%s</b> borradh</item>
<item quantity="two"><b>%s</b> borradh</item>
<item quantity="few"><b>%s</b> borradh</item>
<item quantity="many"><b>%s</b> borradh</item>
<item quantity="other"><b>%s</b> borradh</item>
</plurals>
<string name="title_reblogged_by">Treisithe ag</string>
<string name="title_favourited_by">Ainmnithe ag</string>
<string name="conversation_2_recipients">%1$s agus %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s agus %3$d níos mó</string>
<string name="description_status_media">Meáin: %s</string>
<string name="description_status_cw">Rabhadh ábhair: %s</string>
<string name="description_status_media_no_description_placeholder">Gan tuairisc</string>
<string name="description_status_favourited">Ainmnithe</string>
<string name="description_status_bookmarked">Leabharmharcáilte</string>
<string name="description_visiblity_public">Poiblí</string>
<string name="description_visiblity_unlisted">Neamhliostaithe</string>
<string name="description_visiblity_private">Leanúna</string>
<string name="description_visiblity_direct">Díreach</string>
<string name="hint_list_name">Ainm liosta</string>
<string name="add_hashtag_title">Cuir hashtag leis</string>
<string name="edit_hashtag_hint">Hashtag gan #</string>
<string name="select_list_title">Roghnaigh liosta</string>
<string name="notifications_clear">Glan</string>
<string name="notifications_apply_filter">Scagaire</string>
<string name="filter_apply">Cuir iarratas isteach</string>
<string name="compose_shortcut_short_label">Cum</string>
<string name="compose_preview_image_description">Gníomhartha maidir le híomhá %s</string>
<string name="poll_info_format"> <!-- 15 vóta • 1 uair fágtha --> %1$s •%2$s</string>
<plurals name="poll_info_votes">
<item quantity="one">%s vóta</item>
<item quantity="two">%s vóta</item>
<item quantity="few">%s vóta</item>
<item quantity="many">%s vóta</item>
<item quantity="other">%s vóta</item>
</plurals>
<plurals name="poll_info_people">
<item quantity="one">%s duine</item>
<item quantity="two">%s daoine</item>
<item quantity="few">%s daoine</item>
<item quantity="many">%s daoine</item>
<item quantity="other">%s daoine</item>
</plurals>
<string name="poll_info_time_relative">D\'imigh %s</string>
<string name="poll_info_time_absolute">foircinn ag %s</string>
<string name="poll_info_closed">dúnta</string>
<string name="poll_vote">Vóta</string>
<string name="poll_ended_voted">Tá deireadh le vótaíocht ar vótáil tú ann</string>
<plurals name="poll_timespan_days">
<item quantity="one">%d lá</item>
<item quantity="two">%d lá</item>
<item quantity="few">%d lá</item>
<item quantity="many">%d lá</item>
<item quantity="other">%d lá</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d uair</item>
<item quantity="two">%d uair an chloig</item>
<item quantity="few">%d uair an chloig</item>
<item quantity="many">%d uair an chloig</item>
<item quantity="other">%d uair an chloig</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d soicind</item>
<item quantity="two">%d soicind</item>
<item quantity="few">%d soicind</item>
<item quantity="many">%d soicind</item>
<item quantity="other">%d soicind</item>
</plurals>
<string name="button_continue">Lean ar aghaidh</string>
<string name="button_back">Ar ais</string>
<string name="button_done">Déanta</string>
<string name="report_sent_success">Tuairiscíodh go rathúil @%s</string>
<string name="hint_additional_info">Tuairimí Breise</string>
<string name="report_remote_instance">Seol ar aghaidh chuig %s</string>
<string name="failed_report">Theip ar thuairisciú</string>
<string name="report_description_remote_instance">Is ó fhreastalaí eile an cuntas. Seol cóip gan ainm den tuarascáil ansin freisin\?</string>
<string name="title_accounts">Cuntais</string>
<string name="failed_search">Theip ar chuardach</string>
<string name="pref_title_show_notifications_filter">Taispeáin scagaire Fógraí</string>
<string name="pref_title_enable_swipe_for_tabs">Cumasaigh gotha swipe aistriú idir cluaisíní</string>
<string name="create_poll_title">Vótaíocht</string>
<string name="poll_duration_5_min">5 nóiméad</string>
<string name="poll_duration_30_min">30 nóiméad</string>
<string name="poll_duration_1_hour">1 uair an chloig</string>
<string name="poll_duration_6_hours">6 uair an chloig</string>
<string name="poll_duration_1_day">1 lá</string>
<string name="poll_duration_3_days">3 lá</string>
<string name="poll_duration_7_days">7 lá</string>
<string name="add_poll_choice">Cuir rogha leis</string>
<string name="poll_allow_multiple_choices">Ilroghanna</string>
<string name="poll_new_choice_hint">Rogha %d</string>
<string name="edit_poll">Cuir in Eagar</string>
<string name="post_lookup_error_format">Earráid agus an post á lorg %s</string>
<string name="no_saved_status">Níl aon dréachtaí agat.</string>
<string name="no_scheduled_status">Níl aon stádas sceidealta agat.</string>
<string name="warning_scheduling_interval">Tá eatramh sceidealaithe íosta 5 nóiméad ag Mastodon.</string>
<string name="pref_title_show_cards_in_timelines">Taispeáin réamhamhairc nasc in amlínte</string>
<string name="pref_title_confirm_reblogs">Taispeáin dialóg dearbhaithe sula ndéantar borradh faoi</string>
<string name="title_tab_preferences">Cluaisíní</string>
<string name="status_username_format">\@%s</string>
<string name="action_mute_domain">Tost %s</string>
<string name="action_unmute_domain">Unmute %s</string>
<string name="action_unmute_conversation">Comhrá unmute</string>
<string name="dialog_mute_warning">Tost @%s\?</string>
<string name="pref_title_status_tabs">Cluaisíní</string>
<string name="abbreviated_in_years">in %dy</string>
<string name="abbreviated_in_days">in %dd</string>
<string name="abbreviated_in_hours">in %dh</string>
<string name="abbreviated_in_minutes">in %dm</string>
<string name="abbreviated_in_seconds">in %ds</string>
<string name="abbreviated_years_ago">%dy</string>
<string name="abbreviated_days_ago">%dd</string>
<string name="abbreviated_hours_ago">%dh</string>
<string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</string>
<string name="unreblog_private">Cur i gcoinne</string>
<string name="license_cc_by_4">CC-BY 4.0</string>
<string name="license_cc_by_sa_4">CC-BY-SA 4.0</string>
<string name="unpin_action">Unpin</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="description_status_reblogged">Reblogged</string>
<string name="hashtags">Hashtags</string>
</resources>

@ -2,7 +2,7 @@
<resources>
<string name="title_lists">Liostaichean</string>
<string name="action_lists">Liostaichean</string>
<string name="about_title_activity">Mu dheidhinn</string>
<string name="about_title_activity">Mu Dheidhinn</string>
<string name="action_reset_schedule">Ath-shuidhich</string>
<string name="action_search">Lorg</string>
<string name="action_view_account_preferences">Roighainnean cunntais</string>

@ -33,7 +33,7 @@
<string name="title_followers">Követő</string>
<string name="title_favourites">Kedvencek</string>
<string name="title_mutes">Némított felhasználók</string>
<string name="title_blocks">Blokkolt felhasználók</string>
<string name="title_blocks">Letiltott felhasználók</string>
<string name="title_follow_requests">Követési kérelmek</string>
<string name="title_edit_profile">Profilod szerkesztése</string>
<string name="title_saved_toot">Piszkozatok</string>
@ -372,13 +372,13 @@
<string name="notifications_apply_filter">Szűrés</string>
<string name="filter_apply">Alkalmaz</string>
<string name="compose_shortcut_long_label">Tülk Szerkesztése</string>
<string name="compose_shortcut_short_label">Szerkeszt</string>
<string name="compose_shortcut_short_label">Szerkesztés</string>
<string name="notification_clear_text">Biztos, hogy minden értesítésedet véglegesen törlöd\?</string>
<string name="compose_preview_image_description">Műveletek a %s képpel</string>
<string name="poll_info_format"> <!-- 15 szavazat • 1 óra maradt --> %1$s • %2$s</string>
<plurals name="poll_info_votes">
<item quantity="one">%s szavazat</item>
<item quantity="other">%s szavazat</item>
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="poll_info_time_relative">%s maradt</string>
<string name="poll_info_time_absolute">vége %s</string>
@ -448,7 +448,7 @@
<string name="warning_scheduling_interval">A Mastodonban a legrövidebb ütemezhető időintervallum 5 perc.</string>
<string name="notification_follow_request_name">Követési kérelmek</string>
<string name="pref_title_confirm_reblogs">Jóváhagyó ablak mutatása megtolás előtt</string>
<string name="pref_title_show_cards_in_timelines">Link előnézet mutatása idővonalakon</string>
<string name="pref_title_show_cards_in_timelines">Hivatkozás előnézetének mutatása idővonalakon</string>
<string name="pref_title_enable_swipe_for_tabs">Tabok közötti váltás engedélyezése csúsztatással</string>
<plurals name="poll_info_people">
<item quantity="one"/>

@ -23,7 +23,7 @@
<string name="error_retrieving_oauth_token">Mistókst að fá innskráningarteikn.</string>
<string name="error_compose_character_limit">Stöðufærslan er of löng!</string>
<string name="error_image_upload_size">Skráin verður að vera minni en 8MB.</string>
<string name="error_video_upload_size">Myndskeiðasskrár verða að vera minni en 40MB.</string>
<string name="error_video_upload_size">Myndskeiðaskrár verða að vera minni en 40MB.</string>
<string name="error_media_upload_type">Þessa tegund skrár er ekki hægt að senda inn.</string>
<string name="error_media_upload_opening">Ekki var hægt að opna skrána.</string>
<string name="error_media_upload_permission">Krafist er heimilda til að lesa gögn.</string>

@ -243,11 +243,11 @@
<string name="abbreviated_in_years">in %da</string>
<string name="abbreviated_in_days">in %dg</string>
<string name="abbreviated_in_hours">in %do</string>
<string name="abbreviated_in_minutes">in %dm</string>
<string name="abbreviated_in_seconds">in %ds</string>
<string name="abbreviated_years_ago">%da</string>
<string name="abbreviated_days_ago">%dg</string>
<string name="abbreviated_hours_ago">%do</string>
<string name="abbreviated_in_minutes">in %dmin</string>
<string name="abbreviated_in_seconds">in %ds</string>
<string name="abbreviated_years_ago">%da</string>
<string name="abbreviated_days_ago">%dg</string>
<string name="abbreviated_hours_ago">%do</string>
<string name="abbreviated_minutes_ago">%dm</string>
<string name="abbreviated_seconds_ago">%ds</string>
<string name="follows_you">Seguono te</string>
@ -351,8 +351,7 @@
</string>
<string name="description_visiblity_unlisted"> Non elencato
</string>
<string name="description_visiblity_private"> Seguaci
</string>
<string name="description_visiblity_private">Ti seguono</string>
<string name="description_visiblity_direct"> Diretti
</string>
<string name="hint_list_name">Nome della lista</string>

@ -131,12 +131,12 @@
<string name="label_header">ヘッダー</string>
<string name="link_whats_an_instance">インスタンスとは?</string>
<string name="login_connection">接続中…</string>
<string name="dialog_whats_an_instance">mastodon.social, mstdn.jp, pawoo.netや<!-- --><a href="https://instances.social">その他</a><!-- -->のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
<string name="dialog_whats_an_instance">mastodon.social, icosahedron.website, social.tchncs.deや<a href="https://instances.social">その他</a>のような、あらゆるインスタンスのアドレスやドメインを入力できます。
\n
\nまだアカウントをお持ちでない場合は、参加したいインスタンスの名前を入力することで<!-- -->そのインスタンスにアカウントを作成できます。
\n
\n
\nインスタンスはあなたのアカウントが提供される単独の場所ですが、<!-- -->他のインスタンスのユーザーとあたかも同じ場所にいるように簡単にコミュニケーションをとったりフォローしたりできます。
\n
\n
\nさらに詳しい情報は<a href="https://joinmastodon.org">joinmastodon.org</a>でご覧いただけます。 </string>
<string name="dialog_title_finishing_media_upload">メディアをアップロードしています</string>
<string name="dialog_message_uploading_media">アップロード中…</string>

@ -272,4 +272,5 @@
<string name="no_scheduled_status">Ulac ɣur-k·m ula d yiwet n tjewwiqt yettwasɣawsen.</string>
<string name="action_access_scheduled_toot">Tijewwiqin yettwasɣawsen</string>
<string name="title_scheduled_toot">Tijewwiqin yettwasɣawsen</string>
<string name="pref_title_show_boosts">Sken-d beṭuyat</string>
</resources>

@ -146,23 +146,23 @@
<string name="label_header">헤더</string>
<string name="link_whats_an_instance">인스턴스가 무엇인가요\?</string>
<string name="login_connection">연결 중...</string>
<string name="dialog_whats_an_instance">인스턴스의 도메인 주소나 IP주소를 입력하실 수 있습니다. pawoo.net, twingyeo.kr, qdon.space 등이 있으며, 그 외에도 &lt;a href=“https://instances.social”&gt;더 많은 인스턴스&lt;/a&gt;가 당신을 기다리고 있습니다!
\n
<string name="dialog_whats_an_instance">인스턴스의 도메인 주소나 IP주소를 입력하실 수 있습니다. mastodon.social, icosahedron.website, social.tchncs.de 등이 있으며, 그 외에도 <a href="https://instances.social">더 많은 인스턴스</a>가 당신을 기다리고 있습니다!
\n
\n
\n만약 계정이 없으시다면, 인스턴스 주소를 입력하신 후에 계정을 만드실 수 있습니다.
\n
\n
\n
\n
\n
\n여러분이 어느 인스턴스에 가입하시더라도, 다른 인스턴스에 있는 유저들과 문제 없이 소통하실 수 있습니다.
\n
\n
\n
\n
\n
\n자세한 사항은 &lt;a href=“https://joinmastodon.org”&gt;joinmastodon.org&lt;/a&gt;을 참조하세요. <a href="https://instances.social">more!</a>
\n
\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there.
\n
\n
\nIf you don\'t yet have an account, you can enter the name of the instance you\'d like to join and create an account there.
\n
\nAn instance is a single place where your account is hosted, but you can easily communicate with and follow folks on other instances as though you were on the same site.
\n
\n
\nMore info can be found at <a href="https://joinmastodon.org">joinmastodon.org</a>. </string>
<string name="dialog_title_finishing_media_upload">미디어 업로드 완료</string>
<string name="dialog_message_uploading_media">업로드 중...</string>
@ -378,7 +378,7 @@
<string name="notifications_apply_filter">필터</string>
<string name="filter_apply">적용</string>
<string name="compose_shortcut_long_label">툿 작성하기</string>
<string name="compose_shortcut_short_label">작성</string>
<string name="compose_shortcut_short_label">글쓰기</string>
<string name="notification_clear_text">모든 알림을 영구적으로 삭제하시겠습니까\?</string>
<string name="compose_preview_image_description">이미지 %s에 대한 작업</string>
<string name="poll_info_format"> <!-- 15 명 참여 • 1 시간 남음 --> %1$s • %2$s</string>

@ -353,7 +353,7 @@
<string name="app_theme_auto">Automatisk ved solnedgang</string>
<string name="app_theme_system">Bruk systeminnstillinger</string>
<string name="post_privacy_public">Offentlig</string>
<string name="post_privacy_unlisted">Ikke offentlige tidslinjer</string>
<string name="post_privacy_unlisted">Ikke listet</string>
<string name="post_privacy_followers_only">Kun følgere</string>
<string name="status_text_size_smallest">Minste</string>
<string name="status_text_size_small">Liten</string>

@ -107,7 +107,7 @@
<string name="status_sent_long">Responsa ben enviada.</string>
<string name="hint_domain">Quina instància ?</string>
<string name="hint_compose">A de qué pensatz ?</string>
<string name="hint_content_warning">Avis de contengut</string>
<string name="hint_content_warning">Avís de contengut</string>
<string name="hint_display_name">Nom visible</string>
<string name="hint_note">Biografia</string>
<string name="hint_search">Cercar…</string>
@ -164,8 +164,8 @@
<string name="pref_title_http_proxy_port">Pòrt del servidor proxy HTTP</string>
<string name="pref_default_post_privacy">Privacitat predeterminada dels tuts</string>
<string name="pref_publishing">Publicacion</string>
<string name="post_privacy_public">Public</string>
<string name="post_privacy_unlisted">Pas listada</string>
<string name="post_privacy_public">Publica</string>
<string name="post_privacy_unlisted">Pas listat</string>
<string name="post_privacy_followers_only">Seguidors solament</string>
<string name="pref_status_text_size">Talha de text de l\'estatut</string>
<string name="status_text_size_smallest">Mendre</string>
@ -238,7 +238,7 @@
<string name="send_toot_notification_channel_name">Mandadís dels tuts</string>
<string name="send_toot_notification_cancel_title">Mandadís anullat</string>
<string name="send_toot_notification_saved_content">Una còpia del tut es estat salvat dins los borrolhons</string>
<string name="action_compose_shortcut">Escriure</string>
<string name="action_compose_shortcut">Redactar</string>
<string name="error_no_custom_emojis">L’instància %s es pas compatibla amb los emoji personalizats</string>
<string name="copy_to_clipboard_success">Copiat al quichapapièr</string>
<string name="emoji_style">Estil dels Emoji</string>
@ -310,7 +310,7 @@
<string name="pref_title_thread_filter_keywords">Discutidas</string>
<string name="filter_addition_dialog_title">Ajustar un filtre</string>
<string name="filter_edit_dialog_title">Modificar un filtre</string>
<string name="filter_dialog_remove_button">Levar</string>
<string name="filter_dialog_remove_button">Suprimir</string>
<string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_add_description">Frasa de filtrar</string>
<string name="error_create_list">Creacion impossibla de la lista</string>

@ -142,7 +142,7 @@
<string name="pref_title_notification_filter_mentions">wspomniano o mnie</string>
<string name="pref_title_notification_filter_follows">zaczęto mnie śledzić</string>
<string name="pref_title_notification_filter_reblogs">moje wpisy zostaną podbite</string>
<string name="pref_title_notification_filter_favourites">moje wpisy zostaną polubione</string>
<string name="pref_title_notification_filter_favourites">moje posty zostaną dodane do ulubionych</string>
<string name="pref_title_appearance_settings">Wygląd</string>
<string name="pref_title_app_theme">Motyw</string>
<string name="app_them_dark">Ciemny</string>
@ -178,7 +178,7 @@
<string name="notification_follow_description">Powiadomienia o nowych śledzących</string>
<string name="notification_boost_name">Podbicia</string>
<string name="notification_boost_description">Powiadomienia o podbiciu wpisów</string>
<string name="notification_favourite_name">Ulubione</string>
<string name="notification_favourite_name">Polubione</string>
<string name="notification_favourite_description">Powiadomienia o dodaniu wpisów do ulubionych</string>
<string name="notification_mention_format">%s wspomniał o Tobie</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s i %4$d innych</string>
@ -378,7 +378,7 @@
<string name="notifications_apply_filter">Filtr</string>
<string name="filter_apply">Zastosuj</string>
<string name="compose_shortcut_long_label">Stwórz wpis</string>
<string name="compose_shortcut_short_label">Nowy wpis</string>
<string name="compose_shortcut_short_label">Napisz</string>
<string name="notification_clear_text">Czy jesteś pewien/pewna, że chcesz wyczyścić wszystkie swoje powiadomienia\?</string>
<string name="compose_preview_image_description">Opcje dla obrazu %s</string>
<string name="poll_info_format"> <!-- 15 głosów • pozostała 1 godzina --> %1$s • %2$s</string>

@ -146,7 +146,7 @@
<string name="visibility_unlisted">Não-listado: Não postar em linhas públicas</string>
<string name="visibility_private">Privado: Postar só para seguidores</string>
<string name="visibility_direct">Direto: Postar só para mencionados</string>
<string name="pref_title_edit_notification_settings">Editar Notificações</string>
<string name="pref_title_edit_notification_settings">Editar notificações</string>
<string name="pref_title_notifications_enabled">Notificações</string>
<string name="pref_title_notification_alerts">Alertas</string>
<string name="pref_title_notification_alert_sound">Notificar com som</string>

@ -63,7 +63,7 @@
<string name="action_close"></string>
<string name="action_view_profile">யவிவரம</string>
<string name="action_view_preferences">ிகள</string>
<string name="action_view_favourites">ிிதவகள</string>
<string name="action_view_favourites">ிியவ</string>
<string name="action_view_mutes">ஒலிகபடட பயனரகள</string>
<string name="action_view_blocks">தடயபடட பயனரகள</string>
<string name="action_view_follow_requests">ிபறற கி</string>
@ -259,7 +259,7 @@
<string name="unpin_action">ிி</string>
<string name="pin_action"></string>
<string name="action_view_account_preferences">கணககரிிகள</string>
<string name="error_network">ிய பி ஏறபடடத! உஙகள இண சரியறிகவ!</string>
<string name="error_network">"ிய பி ஏறபடடத! உஙகள இண சரியறிகவ!"</string>
<string name="error_video_upload_size">ி 40MB கக இரக வ.</string>
<string name="error_sender_account_gone"> அனப இயலவி</string>
<string name="title_direct_messages">ரடி தகவல</string>

@ -2,22 +2,22 @@
<resources>
<string name="error_generic">Bir hata oluştu.</string>
<string name="error_network">Bir ağ hatası oluştu! Lütfen bağlantınızı kontrol edin ve tekrar deneyin!</string>
<string name="error_empty">Bu alan boş bırakılmaz.</string>
<string name="error_invalid_domain">Girilen alan alanı geçersiz.</string>
<string name="error_failed_app_registration">Kimlik doğrulama başarısız oldu.</string>
<string name="error_no_web_browser_found">Kullanılabilir bir web tarayıcı bulunmadı.</string>
<string name="error_authorization_unknown">Açıklanmayan kimlik doğrulama hata oluştu.</string>
<string name="error_empty">Bu alan boş bırakılamaz.</string>
<string name="error_invalid_domain">Girilen alan alanı geçersiz</string>
<string name="error_failed_app_registration">Bu sunucuyla kimlik doğrulanamadı.</string>
<string name="error_no_web_browser_found">Kullanılabilir web tarayıcı bulunamadı.</string>
<string name="error_authorization_unknown">Tanımlanamayan bir yetkilendirme hatası oluştu.</string>
<string name="error_authorization_denied">Kimlik doğrulama reddedildi.</string>
<string name="error_retrieving_oauth_token">Giriş tokenı alınamadı.</string>
<string name="error_compose_character_limit">İleti fazlasıyla uzun!</string>
<string name="error_image_upload_size">Dosya 8MB\'den küçük olmalı.</string>
<string name="error_video_upload_size">Video dosyaları 40 MB’den küçük olmalıdır.</string>
<string name="error_media_upload_type">O biçim dosya yüklenmez.</string>
<string name="error_retrieving_oauth_token">Giriş belirteci alınırken hata oluştu.</string>
<string name="error_compose_character_limit">Durum çok uzun!</string>
<string name="error_image_upload_size">Dosya 8 MB\'dan küçük olmalıdır.</string>
<string name="error_video_upload_size">Video dosyaları 40 MB’dan küçük olmalıdır.</string>
<string name="error_media_upload_type">Bu biçimdeki dosyalar yüklenmez.</string>
<string name="error_media_upload_opening">Dosya açılamadı.</string>
<string name="error_media_upload_permission">Medya erişim izni gerekiyor.</string>
<string name="error_media_download_permission">Medya kaydetmek için izin gerekiyor.</string>
<string name="error_media_upload_image_or_video">Aynı iletiye hem video hem resim eklenemez.</string>
<string name="error_media_upload_sending">Yükleme başarsız.</string>
<string name="error_media_upload_permission">Medyayı okumak için izin gerekiyor.</string>
<string name="error_media_download_permission">Medya kaydetmek için izin gerekiyor.</string>
<string name="error_media_upload_image_or_video">Aynı duruma hem video hem resim eklenemez.</string>
<string name="error_media_upload_sending">Yükleme başarısız oldu.</string>
<string name="error_sender_account_gone">Toot gönderilirken hata oluştu.</string>
<string name="title_home">Ana sayfa</string>
<string name="title_notifications">Bildirimler</string>
@ -26,13 +26,13 @@
<string name="title_direct_messages">Direkt Mesajlar</string>
<string name="title_tab_preferences">Sekmeler</string>
<string name="title_view_thread">Dizi</string>
<string name="title_statuses">İletiler</string>
<string name="title_statuses_with_replies">Yanıtlar</string>
<string name="title_statuses_pinned">Tutturulmuş</string>
<string name="title_statuses">Gönderiler</string>
<string name="title_statuses_with_replies">Yanıtlar ile</string>
<string name="title_statuses_pinned">Sabitlenmiş</string>
<string name="title_follows">Takip edilenler</string>
<string name="title_followers">Takipçiler</string>
<string name="title_favourites">Favoriler</string>
<string name="title_mutes">Sesize alınmış kullanıcılar</string>
<string name="title_mutes">Sessize alınmış kullanıcılar</string>
<string name="title_blocks">Engellenmiş kullanıcılar</string>
<string name="title_follow_requests">Takip Etme İstekleri</string>
<string name="title_edit_profile">Profili düzeltme</string>
@ -40,35 +40,35 @@
<string name="title_licenses">Lisanslar</string>
<string name="status_username_format">\@%s</string>
<string name="status_boosted_format">%s yükseltti</string>
<string name="status_sensitive_media_title">Hasas Medya</string>
<string name="status_media_hidden_title">Gizlenmiş medya</string>
<string name="status_sensitive_media_title">Hassas içerik</string>
<string name="status_media_hidden_title">Medya gizlendi</string>
<string name="status_sensitive_media_directions">Görmek için taklayın</string>
<string name="status_content_warning_show_more">Daha Fazla Göster</string>
<string name="status_content_warning_show_less">Daha az</string>
<string name="status_content_show_more">Genişlet</string>
<string name="status_content_show_less">Daralt</string>
<string name="message_empty">Burada hiçbir şey yok.</string>
<string name="footer_empty">Henüz hiç ileti yoktur. Yenilemek için aşağıya çek!</string>
<string name="notification_reblog_format">%s iletini yineledi</string>
<string name="notification_favourite_format">%s ileti favorilerine ekledi</string>
<string name="footer_empty">Burada henüz yok. Yenilemek için aşağıya çekin!</string>
<string name="notification_reblog_format">%s durumunu boost etti</string>
<string name="notification_favourite_format">%s durumunu favorilerine ekledi</string>
<string name="notification_follow_format">%s seni takip etti</string>
<string name="report_username_format">\@%s bildir</string>
<string name="report_comment_hint">Daha fazla yorum?</string>
<string name="action_quick_reply">Hızlı Cevapla</string>
<string name="action_quick_reply">Hızlı Yanıt</string>
<string name="action_reply">Yanıtla</string>
<string name="action_reblog">Yükselt</string>
<string name="action_favourite">Favorile</string>
<string name="action_more">Daha fazla</string>
<string name="action_compose">Oluştur</string>
<string name="action_login">Mastodon ile giriş yap</string>
<string name="action_logout">Çıkış Yap</string>
<string name="action_logout">Çıkış yap</string>
<string name="action_logout_confirm">%1$s hesabından çıkmak istediğine emin misin\?</string>
<string name="action_follow">Takip et</string>
<string name="action_unfollow">Takibi bırak</string>
<string name="action_block">Engelle</string>
<string name="action_unblock">Engeli kaldır</string>
<string name="action_hide_reblogs">Yinelemeleri gizle</string>
<string name="action_show_reblogs">Yinelemeleri aç</string>
<string name="action_hide_reblogs">Boostları gizle</string>
<string name="action_show_reblogs">Boostları göster</string>
<string name="action_report">Bildir</string>
<string name="action_delete">Sil</string>
<string name="action_send">İLET</string>
@ -79,7 +79,7 @@
<string name="action_view_preferences">Ayarlar</string>
<string name="action_view_account_preferences">Hesap Tercihleri</string>
<string name="action_view_favourites">Favoriler</string>
<string name="action_view_mutes">Sesize alınmış kullanıcılar</string>
<string name="action_view_mutes">Sessize alınmış kullanıcılar</string>
<string name="action_view_blocks">Engellenmiş kullanıcılar</string>
<string name="action_view_follow_requests">Takip İstekleri</string>
<string name="action_view_media">Medya</string>
@ -87,13 +87,13 @@
<string name="action_add_media">Medya ekle</string>
<string name="action_photo_take">Fotoğraf çek</string>
<string name="action_share">Paylaş</string>
<string name="action_mute">Sesize al</string>
<string name="action_unmute">Sesizden kaldır</string>
<string name="action_mute">Sessize al</string>
<string name="action_unmute">Sesini aç</string>
<string name="action_mention">Bahset</string>
<string name="action_hide_media">Medyayı gizle</string>
<string name="action_open_drawer">Çekmece aç</string>
<string name="action_save">Kaydet</string>
<string name="action_edit_profile">Profili düzelt</string>
<string name="action_edit_profile">Profili düzenle</string>
<string name="action_edit_own_profile">Düzenle</string>
<string name="action_undo">Geri al</string>
<string name="action_accept">Kabul et</string>
@ -102,31 +102,31 @@
<string name="action_access_saved_toot">Taslaklar</string>
<string name="action_toggle_visibility">Toot görünürlüğü</string>
<string name="action_content_warning">İçerik uyarı</string>
<string name="action_emoji_keyboard">Emoji klavyesi</string>
<string name="action_emoji_keyboard">İfade klavyesi</string>
<string name="action_add_tab">Sekme Ekle</string>
<string name="download_image">%1$s indiriliyor</string>
<string name="action_copy_link">Bağlantıyı kopyala</string>
<string name="action_open_as">Farklı aç %s</string>
<string name="action_share_as">Olarak paylaş …</string>
<string name="send_status_link_to">İletinin adresini paylaş…</string>
<string name="send_status_content_to">İletiyi paylaş…</string>
<string name="send_status_link_to">Durumun adresini paylaş…</string>
<string name="send_status_content_to">Durumu paylaş…</string>
<string name="send_media_to">Medyayı paylaş…</string>
<string name="confirmation_reported">İletildi!</string>
<string name="confirmation_reported">Gönderildi!</string>
<string name="confirmation_unblocked">Kullanıcının engeli kaldırıldı</string>
<string name="confirmation_unmuted">Kullanıcının sesi açıldı</string>
<string name="status_sent">İletildi!</string>
<string name="status_sent">Gönderildi!</string>
<string name="status_sent_long">Yanıt başarıyla gönderildi.</string>
<string name="hint_domain">Hangi sunucu?</string>
<string name="hint_compose">Neler oluyor?</string>
<string name="hint_content_warning">İçerik uyarısı</string>
<string name="hint_display_name">Görüntülenecek isim</string>
<string name="hint_note">Biyo</string>
<string name="hint_display_name">Görünen ad</string>
<string name="hint_note">Hakkında</string>
<string name="hint_search">Ara…</string>
<string name="search_no_results">Sonuç bulunamadı</string>
<string name="label_quick_reply">Yanıt…</string>
<string name="label_avatar">Ava</string>
<string name="label_avatar">Avatar</string>
<string name="label_header">Başlık</string>
<string name="link_whats_an_instance">Sunucu nedir?</string>
<string name="link_whats_an_instance">Sunucunuz nedir\?</string>
<string name="login_connection">Bağlantı kuruluyor…</string>
<string name="dialog_whats_an_instance">Burada her hangi bir Mastodon sunucusunun adresi (mastodon.social, icosahedron.website, social.tchncs.de, ve <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md">daha fazlasını</a>) girebilirsin!
\n
@ -135,12 +135,12 @@
\nHer bir sunucu kendi hesap kayıtlarını tutar ancak diğer sunucularda bulunan insanlarla aynı sitedeymişçesine iletişime geçip takip edebilirsin.
\n
\nDaha fazla bilgi için <a href="https://mastodon.social/about">mastodon.social</a>. </string>
<string name="dialog_title_finishing_media_upload">Medya Yükleme Tamamlanıyor</string>
<string name="dialog_title_finishing_media_upload">Medya yüklemesi tamamlanıyor</string>
<string name="dialog_message_uploading_media">Yükleniyor…</string>
<string name="dialog_download_image">İndir</string>
<string name="dialog_message_cancel_follow_request">Takip isteğini iptal et\?</string>
<string name="dialog_unfollow_warning">Takibi bırak\?</string>
<string name="dialog_delete_toot_warning">Bu iletiyi silmek istiyor musunuz\?</string>
<string name="dialog_unfollow_warning">Takibi bırakmak istiyor musun\?</string>
<string name="dialog_delete_toot_warning">Bu durumu silmek istiyor musunuz\?</string>
<string name="visibility_public">Genel: Herkese açık zaman çizelgesinde göster</string>
<string name="visibility_unlisted">Liste dışı: Herkese açık zaman çizelgelerinde gösterme</string>
<string name="visibility_private">Takipçiler: Sadece takipçilere göster</string>
@ -151,11 +151,11 @@
<string name="pref_title_notification_alert_sound">Sesli uyarı</string>
<string name="pref_title_notification_alert_vibrate">Titreşimle bildir</string>
<string name="pref_title_notification_alert_light">Bildirim ışığıyla bildir</string>
<string name="pref_title_notification_filters">Beni bildir</string>
<string name="pref_title_notification_filters">Bana bildir</string>
<string name="pref_title_notification_filter_mentions">bahsedilince</string>
<string name="pref_title_notification_filter_follows">Takip edilince</string>
<string name="pref_title_notification_filter_reblogs">iletilerim yüksetilince</string>
<string name="pref_title_notification_filter_favourites">iletilerim favorilenince</string>
<string name="pref_title_notification_filter_reblogs">gönderilerim boost edilirse</string>
<string name="pref_title_notification_filter_favourites">gönderilerim favorilere eklenirse</string>
<string name="pref_title_appearance_settings">Görünüş</string>
<string name="pref_title_app_theme">Uygulama teması</string>
<string name="pref_title_timelines">Zaman çizelgeleri</string>
@ -164,20 +164,20 @@
<string name="app_theme_black">Siyah</string>
<string name="app_theme_auto">Gün batımında otomatik</string>
<string name="pref_title_browser_settings">Tarayıcı</string>
<string name="pref_title_custom_tabs">Tarayıcı Özel Sekmelerini Kullan</string>
<string name="pref_title_hide_follow_button">Kaydırırken yeni ileti düğmesi gizlensin</string>
<string name="pref_title_custom_tabs">Chrome özel sekmelerini kullan</string>
<string name="pref_title_hide_follow_button">Kaydırırken durum oluştur düğmesi gizlensin</string>
<string name="pref_title_status_filter">Zaman çizelgesi filtreleme</string>
<string name="pref_title_status_tabs">Sekmeler</string>
<string name="pref_title_show_boosts">Yükseltilenleri göster</string>
<string name="pref_title_show_boosts">Boostları göster</string>
<string name="pref_title_show_replies">Yanıtları göster</string>
<string name="pref_title_show_media_preview">Medya önizlemelerini göster</string>
<string name="pref_title_show_media_preview">Medya önizlemelerini indir</string>
<string name="pref_title_proxy_settings">Proxy</string>
<string name="pref_title_http_proxy_settings">HTTP Proxy</string>
<string name="pref_title_http_proxy_enable">HTTP ağ vekilini etkinleştir</string>
<string name="pref_title_http_proxy_enable">HTTP vekil sunucusunu etkinleştir</string>
<string name="pref_title_http_proxy_server">HTTP ağ vekili sunucusu</string>
<string name="pref_title_http_proxy_port">HTTP proxy portu</string>
<string name="pref_default_post_privacy">Varsayılan ileti gizliliği</string>
<string name="pref_default_media_sensitivity">Medyaları her zaman hassas olarak işaretle</string>
<string name="pref_default_post_privacy">Varsayılan gönderi gizliliği</string>
<string name="pref_default_media_sensitivity">Medyayı her zaman hassas olarak işaretle</string>
<string name="pref_publishing">Yayınlama (sunucuyla eşitlenir)</string>
<string name="pref_failed_to_sync">Ayarları eşitleme başarısız</string>
<string name="post_privacy_public">Herkese açık</string>
@ -189,12 +189,12 @@
<string name="status_text_size_medium">Orta</string>
<string name="status_text_size_large">Büyük</string>
<string name="status_text_size_largest">En büyük</string>
<string name="notification_mention_name">Senden bahsedildi</string>
<string name="notification_mention_descriptions">Yeni bahsedilenler hakkında bildirim</string>
<string name="notification_mention_name">Yeni Bahsetmeler</string>
<string name="notification_mention_descriptions">Yeni bahsetmeler hakkında bildirim</string>
<string name="notification_follow_name">Yeni Takipçiler</string>
<string name="notification_follow_description">Yeni takipçiler hakkında bildirim</string>
<string name="notification_boost_name">Yükseltilenler</string>
<string name="notification_boost_description">İletilerin yinelendiğinde</string>
<string name="notification_boost_description">Durumunuz boost edildiğinde bildirim</string>
<string name="notification_favourite_name">Favoriler</string>
<string name="notification_favourite_description">Tootların favori olarak işaretlendiğinde</string>
<string name="notification_mention_format">%s senden bahsetti</string>
@ -205,7 +205,7 @@
<string name="description_account_locked">Kitli Hesap</string>
<string name="about_title_activity">Hakkında</string>
<string name="about_tusky_version">Tusky %s</string>
<string name="about_tusky_license">Tusky özgür ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görüntüleyebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<string name="about_tusky_license">Tusky ücretsiz ve açık kaynak bir yazılımdır. GNU Genel Kamu Lisansı sürüm 3 altında lisanslanmıştır. Lisansı buradan görebilirsiniz: https://www.gnu.org/licenses/gpl-3.0.en.html</string>
<!-- note to translators:
* you should think of “free” as in “free speech,” not as in “free beer”.
We sometimes call it “libre software,” borrowing the French or Spanish word for “free” as in freedom,
@ -217,8 +217,8 @@
<string name="about_bug_feature_request_site">Hata raporları &amp; özellik istekleri:
\n https://github.com/tuskyapp/Tusky/issues</string>
<string name="about_tusky_account">Tusky\'in Profili</string>
<string name="status_share_content">İletinin içeriğini paylaş</string>
<string name="status_share_link">İletinin adresini paylaş</string>
<string name="status_share_content">Durum içeriğini paylaş</string>
<string name="status_share_link">Durumun adresini paylaş</string>
<string name="status_media_images">Görseller</string>
<string name="status_media_video">Video</string>
<string name="state_follow_requested">Takip istekleri</string>
@ -247,43 +247,43 @@
\n(%d karakter limiti)</string>
<string name="action_set_caption">Başlık belirle</string>
<string name="action_remove">Kaldır</string>
<string name="lock_account_label">Hesabı Kilitle</string>
<string name="lock_account_label_description">Aktif edilirse takipçileri elle onaylamanız gerekir</string>
<string name="lock_account_label">Hesabı kilitle</string>
<string name="lock_account_label_description">Takipçileri elle onaylamanızı gerektirir</string>
<string name="compose_save_draft">Taslaklara kaydedilsin mi\?</string>
<string name="send_toot_notification_title">İleti gönderiliyor…</string>
<string name="send_toot_notification_error_title">İleti gönderilirken hata oluştu</string>
<string name="send_toot_notification_title">Durum gönderiliyor…</string>
<string name="send_toot_notification_error_title">Durum gönderilirken hata oluştu</string>
<string name="send_toot_notification_channel_name">Toot gönderiliyor</string>
<string name="send_toot_notification_cancel_title">Gönderme iptal edildi</string>
<string name="send_toot_notification_saved_content">İletinin bir kopyası taslaklara kaydedildi</string>
<string name="send_toot_notification_saved_content">Tootun bir kopyası taslaklara kaydedildi</string>
<string name="action_compose_shortcut">Oluştur</string>
<string name="error_no_custom_emojis">%s sunucunuzun özel emoji seti yok</string>
<string name="error_no_custom_emojis">%s sunucunuzun herhangi bir özel ifadeye sahip değil</string>
<string name="copy_to_clipboard_success">Panoya kopyalandı</string>
<string name="emoji_style">Emoji tipi</string>
<string name="emoji_style">İfade stili</string>
<string name="system_default">Sistem varsayılanı</string>
<string name="download_fonts">Emoji setini kullanabilmek için indirmeniz gerekli</string>
<string name="download_fonts">Önce bu ifade paketini indirmeniz gerekecek</string>
<string name="performing_lookup_title">Araştırılıyor…</string>
<string name="expand_collapse_all_statuses">Tüm durumları Genişlet/Küçült</string>
<string name="action_open_toot">İleti</string>
<string name="action_open_toot">Durumu</string>
<string name="restart_required">Uygulamayı yeniden başlatmanız lazım</string>
<string name="restart_emoji">Değişikliklerin uygulanabilmesi için uygulama yeniden başlatılmalı</string>
<string name="restart_emoji">Bu değişiklikleri uygulamak için Tusky\'yi yeniden başlatmanız gerekecek</string>
<string name="later">Sonra</string>
<string name="restart">Yeniden başlat</string>
<string name="caption_systememoji">Cihazınızın varsayılan emoji seti</string>
<string name="caption_blobmoji">Android 4.4 — 7.1\'den bilinen baloncuk emojisi</string>
<string name="caption_twemoji">Mastodon\'un standart emoji seti</string>
<string name="caption_systememoji">Cihazınızın varsayılan ifade paketi</string>
<string name="caption_blobmoji">Android 4.4–7.1\'den bilinen Blob ifadeleri</string>
<string name="caption_twemoji">Mastodon\'un standart ifade paketi</string>
<string name="download_failed">İndirme başarısız</string>
<string name="account_moved_description">%1$s buraya taşındı:</string>
<string name="unreblog_private">Yinelemeyi geri al</string>
<string name="license_description">Tusky aşağıdakı açık kaynaklı projelerden kod ve materyal içeriyor:</string>
<string name="license_apache_2">Apache Lisansı altında lisanslanmıştır (kopya aşağıda)</string>
<string name="unreblog_private">Boostu geri al</string>
<string name="license_description">Tusky, aşağıdaki açık kaynaklı projelerden kod ve varlıklar içerir:</string>
<string name="license_apache_2">Apache Lisansı altında lisanslanmıştır (aşağıdaki kopyası)</string>
<string name="license_cc_by_4">CC-BY 4.0</string>
<string name="license_cc_by_sa_4">CC-BY-SA 4.0</string>
<string name="profile_metadata_label">Profil Meta verisi</string>
<string name="profile_metadata_label">Profil meta verileri</string>
<string name="profile_metadata_add">veri ekle</string>
<string name="profile_metadata_label_label">Etiket</string>
<string name="profile_metadata_content_label">İçerik</string>
<string name="pref_title_absolute_time">Kesin zaman kullan</string>
<string name="label_remote_account">Aşağıdaki bilgiler, kullanıcının profilini tam olarak yansıtmayabilir. Tarayıcıda tam profili görüntülemek için dokunun.</string>
<string name="label_remote_account">Aşağıdaki bilgiler kullanıcının profilini tam olarak yansıtmayabilir. Tam profili tarayıcıda açmak için dokunun.</string>
<string name="unpin_action">Sabitlemeyi kaldır</string>
<string name="pin_action">Sabitle</string>
<plurals name="favs">
@ -301,7 +301,7 @@
<string name="conversation_more_recipients">%1$s, %2$s ve %3$d daha fazlası</string>
<string name="max_tab_number_reached">%1$d maksimum sekme sayısına ulaşıldı</string>
<string name="title_domain_mutes">Gizli alanadları</string>
<string name="action_unreblog">Yinelemeyi kaldır</string>
<string name="action_unreblog">Boostu kaldır</string>
<string name="action_unfavourite">Favoriyi kaldır</string>
<string name="action_view_domain_mutes">Gizli alanadları</string>
<string name="action_mute_domain">%s alan adını sessize al</string>
@ -317,7 +317,7 @@
<string name="pref_title_timeline_filters">Filtreler</string>
<string name="app_theme_system">Sistem tasarımını kullan</string>
<string name="pref_title_language">Dil</string>
<string name="pref_title_animate_gif_avatars">Hareketli GIF avatarları oynat</string>
<string name="pref_title_animate_gif_avatars">GIF avatarlarını oynat</string>
<string name="notification_poll_name">Anketler</string>
<string name="notification_poll_description">Sona eren anketlerle ilgili bildirimler</string>
<string name="pref_title_public_filter_keywords">Genel zaman çizelgesi</string>
@ -327,7 +327,7 @@
<string name="filter_dialog_remove_button">Kaldır</string>
<string name="filter_dialog_update_button">Güncelle</string>
<string name="filter_dialog_whole_word">Tüm kelime</string>
<string name="filter_dialog_whole_word_description">Anahtar kelime veya kelime öbeği yalnızca alfasayısal olduğunda, yalnızca tüm sözcükle eşleşirse uygulanır</string>
<string name="filter_dialog_whole_word_description">Anahtar kelime veya kelime öbeği yalnızca alfanümerik olduğunda, yalnızca tüm kelimeyle eşleşirse uygulanır.</string>
<string name="filter_add_description">Filtrelenecek ifade</string>
<string name="error_create_list">Liste oluşturulamadı</string>
<string name="error_rename_list">Liste yeniden adlandırılamadı</string>
@ -336,10 +336,10 @@
<string name="action_rename_list">Listeyi yeniden adlandır</string>
<string name="action_delete_list">Listeyi sil</string>
<string name="action_edit_list">Listeyi düzenle</string>
<string name="hint_search_people_list">Takip ettiğiniz kişileri ara</string>
<string name="hint_search_people_list">Takip ettiğim kişilerde ara</string>
<string name="action_add_to_list">Listeye hesap ekle</string>
<string name="action_remove_from_list">Hesabı listeden kaldır</string>
<string name="caption_notoemoji">Google\'ın mevcut emoji seti</string>
<string name="action_remove_from_list">Listeden hesabı kaldır</string>
<string name="caption_notoemoji">Google\'ın mevcut ifade paketi</string>
<string name="description_status_media">Medya: %s</string>
<string name="description_status_cw">İçerik uyarısı: %s</string>
<string name="description_status_media_no_description_placeholder">Açıklama yok</string>
@ -353,11 +353,11 @@
<string name="hint_list_name">Liste adı</string>
<string name="edit_hashtag_hint"># olmadan hashtag</string>
<string name="notifications_clear">Temizle</string>
<string name="notifications_apply_filter">Filtre</string>
<string name="notifications_apply_filter">Filtrele</string>
<string name="filter_apply">Uygula</string>
<string name="compose_shortcut_long_label">İleti Oluştur</string>
<string name="compose_shortcut_long_label">Toot Oluştur</string>
<string name="compose_shortcut_short_label">Oluştur</string>
<string name="notification_clear_text">Tüm bildirimleri kalıcı olarak silmek istediğinden emin misin\?</string>
<string name="notification_clear_text">Tüm bildirimleri kalıcı olarak silmek istediğinizden emin misiniz\?</string>
<string name="compose_preview_image_description">%s görüntüsü için eylemler</string>
<string name="poll_info_format"> <!-- 15 oy • 1 saat kaldı --> %1$s • %2$s</string>
<plurals name="poll_info_votes">
@ -378,8 +378,8 @@
<item quantity="other">%d saat</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one"/>
<item quantity="other"/>
<item quantity="one">%d dakika</item>
<item quantity="other">%d dakika</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d saniye</item>
@ -391,27 +391,27 @@
<string name="report_sent_success">\@%s başarıyla bildirildi</string>
<string name="hint_additional_info">Ek Yorumlar</string>
<string name="report_remote_instance">%s adresine ilet</string>
<string name="failed_fetch_statuses">İletiler getirilemedi</string>
<string name="report_description_1">"Bildirim sunucu yöneticinize gönderilecektir. Bu hesabı neden bildirdiğinizle ilgili açıklama yapabilirsiniz:"</string>
<string name="failed_fetch_statuses">Durumlar getirilemedi</string>
<string name="report_description_1">"Rapor, sunucu moderatörünüze gönderilecektir. Aşağıdan bu hesabı neden bildirdiğinizle ilgili bir açıklama sağlayabilirsiniz:"</string>
<string name="report_description_remote_instance">Hesap başka bir sunucudan. Raporun anonim bir kopyasını da oraya gönderilsin mi\?</string>
<string name="pref_title_show_notifications_filter">Bildirim filtresini göster</string>
<string name="action_mentions">Bahsedenler</string>
<string name="action_open_reblogger">Yineleyen yayıncıyı aç</string>
<string name="action_open_reblogged_by">Yinelemeleri aç</string>
<string name="action_open_reblogged_by">Boostları göster</string>
<string name="title_mentions_dialog">Bahsedenler</string>
<string name="action_open_media_n">#%d medyayı aç</string>
<string name="title_bookmarks">Yer imleri</string>
<string name="title_scheduled_toot">Zamanlanmış iletiler</string>
<string name="title_scheduled_toot">Zamanlanmış tootlar</string>
<string name="action_bookmark">Yerimi</string>
<string name="action_edit">Düzenle</string>
<string name="action_delete_and_redraft">Sil ve düzenle</string>
<string name="action_view_bookmarks">Yer imleri</string>
<string name="action_add_poll">Anket ekle</string>
<string name="action_access_scheduled_toot">Zamanlanmış iletiler</string>
<string name="action_schedule_toot">İleti zamanla</string>
<string name="action_access_scheduled_toot">Zamanlanmış tootlar</string>
<string name="action_schedule_toot">Tootu zamanla</string>
<string name="action_reset_schedule">Sıfırla</string>
<string name="dialog_redraft_toot_warning">Bu iletiyi silip yeniden düzenlemek istiyor musun\?</string>
<string name="pref_title_bot_overlay">Botlar için gösterge göster</string>
<string name="dialog_redraft_toot_warning">Bu durumu silip yeniden düzenlemek istiyor musunuz\?</string>
<string name="pref_title_bot_overlay">Botlar için işaret göster</string>
<string name="about_powered_by_tusky">Tusky tarafından desteklenmektedir</string>
<string name="description_status_bookmarked">Yerimine eklendi</string>
<string name="select_list_title">Liste seç</string>
@ -430,19 +430,19 @@
<string name="poll_allow_multiple_choices">Çoklu seçim</string>
<string name="edit_poll">Düzenle</string>
<string name="replying_to">Yanıtlanıyor: %s</string>
<string name="profile_badge_bot_text">Alt Metin</string>
<string name="confirmation_domain_unmuted">%s gizleme</string>
<string name="mute_domain_warning_dialog_ok">Alan adından herşeyi gizle</string>
<string name="profile_badge_bot_text">Bot</string>
<string name="confirmation_domain_unmuted">%s alan adını gizleme</string>
<string name="mute_domain_warning_dialog_ok">Alan adından her şeyi gizle</string>
<string name="pref_title_alway_open_spoiler">Hassas içerikleri göster</string>
<string name="poll_info_time_absolute">bitiş %s</string>
<string name="poll_info_time_absolute">%s sona eriyor</string>
<string name="failed_report">Bildirilemedi</string>
<string name="poll_new_choice_hint">Seçenek %d</string>
<string name="post_lookup_error_format">%s ileti aranırken hata oluştu</string>
<string name="post_lookup_error_format">%s gönderisi aranırken hata oluştu</string>
<string name="no_saved_status">Hiç taslağınız yok.</string>
<string name="no_scheduled_status">Planlanmış durumunuz yok.</string>
<string name="no_scheduled_status">Zamanlanmış durumunuz yok.</string>
<string name="reblog_private">Kendi kitlenize yükseltin</string>
<string name="hashtags">Hashtag\'ler</string>
<string name="pref_title_confirm_reblogs">Yinelemeden önce onay iletişim kutusunu göster</string>
<string name="pref_title_confirm_reblogs">Boost etmeden önce onay iletişim kutusunu göster</string>
<string name="pref_title_show_cards_in_timelines">Bağlantı önizlemelerini zaman çizelgesinde göster</string>
<string name="pref_title_enable_swipe_for_tabs">Sekmeler arasında geçiş yapmak için kaydırma hareketini etkinleştir</string>
<plurals name="poll_info_people">
@ -462,5 +462,12 @@
<string name="action_unmute_notifications_desc">%s kullanıcısından gelen bildirimleri yoksay</string>
<string name="action_unmute_desc">%s sesini aç</string>
<string name="notification_follow_request_format">%s seni takip etmek istiyor</string>
<string name="error_audio_upload_size">Ses dosyaları 40 MB\'tan büyük olamaz.</string>
<string name="error_audio_upload_size">Ses dosyaları 40 MB\'dan büyük olamaz.</string>
<string name="action_unmute_conversation">Sohbetin sesini aç</string>
<string name="pref_title_notification_filter_follow_requests">takip istendi</string>
<string name="action_mute_conversation">Sohbeti sessize al</string>
<string name="pref_main_nav_position">Ana gezinti konumu</string>
<string name="pref_title_gradient_for_media">Gizli medya için renkli gradyanlar göster</string>
<string name="error_failed_set_caption">Başlık ayarlama başarısız oldu</string>
<string name="warning_scheduling_interval">Mastodon\'un minimum 5 dakikalık zamanlama aralığı vardır.</string>
</resources>

@ -454,4 +454,5 @@
<string name="action_mute_notifications_desc">Ẩn thông báo từ %s</string>
<string name="action_unmute_notifications_desc">Bỏ ẩn thông báo từ %s</string>
<string name="action_unmute_desc">Bỏ ẩn %s</string>
<string name="action_unmute_domain">Bỏ ẩn %s</string>
</resources>

@ -4,19 +4,19 @@
<string name="error_network">网络请求出错,请检查互联网连接并重试!</string>
<string name="error_empty">内容不能为空。</string>
<string name="error_invalid_domain">该域名无效</string>
<string name="error_failed_app_registration">无法连接此服务器</string>
<string name="error_no_web_browser_found">没有可用的浏览器</string>
<string name="error_failed_app_registration">无法连接此服务器</string>
<string name="error_no_web_browser_found">没有可用的浏览器</string>
<string name="error_authorization_unknown">认证过程出现未知错误。</string>
<string name="error_authorization_denied">授权被拒绝。</string>
<string name="error_retrieving_oauth_token">无法获取登录信息</string>
<string name="error_retrieving_oauth_token">无法获取登录令牌。</string>
<string name="error_compose_character_limit">嘟文太长了!</string>
<string name="error_image_upload_size">文件大小限制 8MB</string>
<string name="error_video_upload_size">视频文件大小限制 40MB</string>
<string name="error_media_upload_type">无法上传此类型的文件</string>
<string name="error_media_upload_opening">此文件无法打开</string>
<string name="error_media_upload_permission">需要授予 Tusky 读取媒体文件的权限</string>
<string name="error_image_upload_size">文件大小限制 8MB</string>
<string name="error_video_upload_size">视频文件大小限制 40MB</string>
<string name="error_media_upload_type">无法上传此类型的文件</string>
<string name="error_media_upload_opening">此文件无法打开</string>
<string name="error_media_upload_permission">需要授予 Tusky 读取媒体文件的权限</string>
<string name="error_media_download_permission">需要授予 Tusky 写入存储空间的权限。</string>
<string name="error_media_upload_image_or_video">无法在嘟文中同时插入视频和图片</string>
<string name="error_media_upload_image_or_video">无法在嘟文中同时插入视频和图片</string>
<string name="error_media_upload_sending">媒体文件上传失败。</string>
<string name="error_sender_account_gone">嘟文发送时出错。</string>
<string name="title_home">主页</string>
@ -48,12 +48,12 @@
<string name="status_content_show_more">展开</string>
<string name="status_content_show_less">折叠</string>
<string name="message_empty">还没有内容。</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新</string>
<string name="notification_reblog_format">%s 转嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string>
<string name="notification_follow_format">%s 关注了你</string>
<string name="report_username_format">报告用户 @%s 的滥用行为</string>
<string name="report_comment_hint">报告更多信息</string>
<string name="report_comment_hint">是否有更多信息需报告?</string>
<string name="action_quick_reply">快速回复</string>
<string name="action_reply">回复</string>
<string name="action_reblog">转嘟</string>
@ -141,7 +141,7 @@
<string name="label_quick_reply">回复…</string>
<string name="label_avatar">头像</string>
<string name="label_header">标题</string>
<string name="link_whats_an_instance">什么是实例</string>
<string name="link_whats_an_instance">需要帮助</string>
<string name="login_connection">正在连接…</string>
<string name="dialog_whats_an_instance">请输入你帐号所在的 Mastodon 站点的域名,比如 pawoo.net,acg.mn,wxw.moe,<a href="https://instances.social">等等</a>
\n\n还没有 Mastodon 帐号?你也可以输入想注册的 Mastodon 站点的域名,然后在该服务器创建新的帐号并授权 Tusky 登入。
@ -327,7 +327,7 @@
<string name="profile_metadata_label_label">标签</string>
<string name="profile_metadata_content_label">内容</string>
<string name="pref_title_absolute_time">嘟文显示精确时间</string>
<string name="label_remote_account">以下信息可能并不完整,要查看完整资料请使用浏览器打开</string>
<string name="label_remote_account">以下信息可能并不完整,要查看完整资料请使用浏览器打开</string>
<string name="unpin_action">取消置顶</string>
<string name="pin_action">置顶</string>
<plurals name="favs">
@ -430,7 +430,7 @@
<string name="button_continue">继续</string>
<string name="button_back">返回</string>
<string name="button_done">完成</string>
<string name="report_sent_success">"成功回报 @%s"</string>
<string name="report_sent_success">成功报告 @%s</string>
<string name="hint_additional_info">附加留言</string>
<string name="report_remote_instance">转发到 %s</string>
<string name="failed_report">回报失败</string>
@ -455,7 +455,33 @@
<string name="post_lookup_error_format">查找帖子时出错 %s</string>
<string name="no_saved_status">您没有草稿。</string>
<string name="no_scheduled_status">您没有任何预定状态。</string>
<string name="warning_scheduling_interval">Mastodon的最小调度间隔为5分钟。</string>
<string name="warning_scheduling_interval">Mastodon的最小预定时间为5分钟。</string>
<string name="notification_follow_request_name">关注请求</string>
<string name="hashtags">话题</string>
<string name="pref_title_confirm_reblogs">在转嘟前显示确认对话框</string>
<string name="pref_title_show_cards_in_timelines">在时间轴上显示链接预览</string>
<string name="pref_title_enable_swipe_for_tabs">启用划动手势以切换标签页</string>
<plurals name="poll_info_people">
<item quantity="one">%s 个人</item>
<item quantity="other"></item>
</plurals>
<string name="compose_preview_image_description">处理图片 %s</string>
<string name="add_hashtag_title">添加话题名</string>
<string name="notification_follow_request_description">关注请求的通知</string>
<string name="pref_main_nav_position_option_bottom">底部</string>
<string name="pref_main_nav_position_option_top">顶部</string>
<string name="pref_title_gradient_for_media">为隐藏的媒体文件显示彩色模糊预览</string>
<string name="pref_title_notification_filter_follow_requests">有关注申请</string>
<string name="dialog_mute_hide_notifications">隐藏通知</string>
<string name="dialog_mute_warning">确定隐藏 @%s?</string>
<string name="dialog_block_warning">确定屏蔽 @%s?</string>
<string name="mute_domain_warning">确定要完全屏蔽 %s 吗?您将不能在公共时间轴和通知内看见来自此域名的内容。您在此域名上的关注者将会被移除。</string>
<string name="action_unmute_conversation">取消隐藏本会话的通知</string>
<string name="action_mute_conversation">隐藏本会话的通知</string>
<string name="action_unmute_domain">取消隐藏 %s</string>
<string name="action_mute_domain">隐藏 %s</string>
<string name="action_mute_notifications_desc">隐藏来自 %s 的通知</string>
<string name="action_unmute_notifications_desc">取消隐藏来自 %s 的通知</string>
<string name="action_unmute_desc">取消隐藏 %s</string>
<string name="notification_follow_request_format">%s 请求关注你</string>
</resources>

@ -30,7 +30,7 @@
<string name="title_statuses_with_replies">嘟文和回覆</string>
<string name="title_statuses_pinned">已置頂</string>
<string name="title_follows">正在關注</string>
<string name="title_followers">關注者</string>
<string name="title_followers">關注者</string>
<string name="title_favourites">我的收藏</string>
<string name="title_mutes">被靜音的使用者</string>
<string name="title_blocks">被封鎖的使用者</string>

@ -133,7 +133,7 @@
<string name="status_sent_long">成功发布回复</string>
<string name="hint_domain">域名</string>
<string name="hint_compose">有什么新鲜事?</string>
<string name="hint_content_warning">内容提醒</string>
<string name="hint_content_warning">设置内容提醒信息</string>
<string name="hint_display_name">昵称</string>
<string name="hint_note">简介</string>
<string name="hint_search">搜索…</string>
@ -158,7 +158,7 @@
<string name="visibility_unlisted">不公开:所有人可见,但不会出现在公共时间轴上</string>
<string name="visibility_private">仅关注者:只有经过你确认后关注你的用户可见</string>
<string name="visibility_direct">私信:只有被提及的用户可见</string>
<string name="pref_title_edit_notification_settings">通知设置</string>
<string name="pref_title_edit_notification_settings">通知</string>
<string name="pref_title_notifications_enabled">通知</string>
<string name="pref_title_notification_alerts">提醒</string>
<string name="pref_title_notification_alert_sound">通知铃声</string>

@ -143,7 +143,7 @@
<string name="label_header">標題</string>
<string name="link_whats_an_instance">什麼是站點?</string>
<string name="login_connection">正在連線…</string>
<string name="dialog_whats_an_instance">請輸入你帳號所在的 Mastodon 站點的域名或地址</string>
<string name="dialog_whats_an_instance">"請輸入你帳號所在的 Mastodon 站點的域名或地址 "</string>
<string name="dialog_title_finishing_media_upload">正在完成上傳…</string>
<string name="dialog_message_uploading_media">正在上傳…</string>
<string name="dialog_download_image">下載</string>
@ -424,4 +424,5 @@
<string name="report_description_remote_instance">該帳戶來自其他伺服器。向那裡發送一份匿名的報告副本?</string>
<string name="pref_title_show_notifications_filter">顯示通知過濾器</string>
<string name="hashtags">話題</string>
<string name="notification_follow_request_name">關注請求</string>
</resources>

@ -19,4 +19,9 @@
<attr name="status_text_medium" format="dimension" />
<attr name="status_text_large" format="dimension" />
<attr name="chat_me_color" format="reference|color" />
<attr name="chat_other_color" format="reference|color" />
<attr name="chat_me_text_color" format="reference|color" />
<attr name="chat_other_text_color" format="reference|color" />
<attr name="chat_date_text_color" format="reference|color" />
</resources>

@ -7,6 +7,7 @@
<color name="tusky_green">#19a341</color>
<color name="tusky_green_light">#25d069</color>
<color name="red">#f00</color>
<color name="white">#fff</color>
<color name="black">#000</color>

@ -51,4 +51,16 @@
<dimen name="adaptive_bitmap_outer_size">108dp</dimen>
<dimen name="fabMargin">16dp</dimen>
<dimen name="chat_base_padding">16dp</dimen>
<dimen name="chat_large_padding">64dp</dimen>
<dimen name="chat_between_messages_padding">4dp</dimen>
<dimen name="chat_message_v_padding">8dp</dimen>
<dimen name="chat_radius">10dp</dimen>
<dimen name="chat_message_h_padding">10dp</dimen>
<dimen name="chat_avatar_size">36dp</dimen>
<dimen name="chat_small_padding">8dp</dimen>
<dimen name="chat_radius_fix">12dp</dimen>
<dimen name="chat_media_preview_item_height">160dp</dimen>
<dimen name="chat_message_max_width">300dp</dimen>
</resources>

@ -79,6 +79,11 @@
<item name="swipeRefreshLayoutProgressSpinnerBackgroundColor">?attr/colorSurface</item>
<item name="chat_me_color">@color/tusky_blue</item>
<item name="chat_me_text_color">@color/textColorPrimary</item>
<item name="chat_other_color">@color/colorPrimaryDark</item>
<item name="chat_other_text_color">@color/textColorPrimary</item>
<item name="chat_date_text_color">@color/textColorSecondary</item>
</style>
<style name="ViewMediaActivity.AppBarLayout" parent="ThemeOverlay.AppCompat">
@ -160,4 +165,18 @@
<item name="materialDrawerDrawCircularShadow">false</item>
</style>
<style name="TextAppearance.Chat" parent="TextAppearance.AppCompat" />
<style name="TextAppearance.Chat.Date">
<item name="android:textSize">?attr/status_text_small</item>
<item name="android:textColor">?attr/chat_date_text_color</item>
</style>
<style name="TextAppearance.Chat.Content">
<item name="android:textSize">?attr/status_text_medium</item>
</style>
<style name="TextAppearance.Chat.Content.Me">
<item name="android:textColor">@color/textColorPrimary</item>
</style>
<style name="TextAppearance.Chat.Content.Other">
<item name="android:textColor">@color/textColorPrimary</item>
</style>
</resources>

@ -23,7 +23,7 @@ import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.common.MediaUploader
import com.keylesspalace.tusky.db.*
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.*
@ -42,7 +42,6 @@ import org.mockito.Mockito.mock
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
import java.lang.Math.pow
/**
* Created by charlag on 3/7/18.

Loading…
Cancel
Save