Add language dropdown to compose view (#2651)

* Add UI for selecting post language

* Apply selected language when sending status

* Save/restore post language with drafts

* Fall back to english if the configured language isn't found in the locale list (no-NB)

* Remove comment about no_NB

* Move language dropdown to top of compose view

* Preserve language when redrafting

* Set default language to target post's language when replying

* Add Tusky license header to new source file

* Tweak language dropdown button width
This commit is contained in:
Levi Bard 2022-08-31 18:53:57 +02:00 committed by GitHub
parent c638ad7e6b
commit 0041acf2d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1140 additions and 28 deletions

View File

@ -0,0 +1,953 @@
{
"formatVersion": 1,
"database": {
"version": 42,
"identityHash": "a62399cb3859de7fcbb9bd7053f7cb1d",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduledAt",
"columnName": "scheduledAt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `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, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsUpdates",
"columnName": "notificationsUpdates",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "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": "oauthScopes",
"columnName": "oauthScopes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unifiedPushUrl",
"columnName": "unifiedPushUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPubKey",
"columnName": "pushPubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPrivKey",
"columnName": "pushPrivKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushAuth",
"columnName": "pushAuth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushServerKey",
"columnName": "pushServerKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "videoSizeLimit",
"columnName": "videoSizeLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "imageSizeLimit",
"columnName": "imageSizeLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "imageMatrixLimit",
"columnName": "imageMatrixLimit",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxMediaAttachments",
"columnName": "maxMediaAttachments",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFields",
"columnName": "maxFields",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFieldNameLength",
"columnName": "maxFieldNameLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxFieldValueLength",
"columnName": "maxFieldValueLength",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"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, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, 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": "repliesCount",
"columnName": "repliesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.repliesCount",
"columnName": "s_repliesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.language",
"columnName": "s_language",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a62399cb3859de7fcbb9bd7053f7cb1d')"
]
}
}

View File

@ -0,0 +1,43 @@
/* Copyright 2022 Tusky contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.content.Context
import android.graphics.Typeface
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import com.keylesspalace.tusky.util.ThemeUtils
import java.util.Locale
class LocaleAdapter(context: Context, resource: Int, locales: List<Locale>) : ArrayAdapter<Locale>(context, resource, locales) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getView(position, convertView, parent) as TextView).apply {
setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary))
typeface = Typeface.DEFAULT_BOLD
text = super.getItem(position)?.language?.uppercase()
}
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
return (super.getDropDownView(position, convertView, parent) as TextView).apply {
setTextColor(ThemeUtils.getColor(context, android.R.attr.textColorTertiary))
val locale = super.getItem(position)
text = "${locale?.displayLanguage} (${locale?.getDisplayLanguage(locale)})"
}
}
}

View File

@ -35,6 +35,7 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.PopupMenu
@ -65,6 +66,7 @@ import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.LocaleAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
@ -244,6 +246,7 @@ class ComposeActivity :
binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupLanguageSpinner(getInitialLanguage(composeOptions?.language))
setupComposeField(preferences, viewModel.startingText)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
@ -476,6 +479,40 @@ class ComposeActivity :
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
}
private fun setupLanguageSpinner(initialLanguage: String?) {
val locales = Locale.getAvailableLocales()
.filter { it.country.isNullOrEmpty() && it.script.isNullOrEmpty() && it.variant.isNullOrEmpty() } // Only "base" languages, "en" but not "en_DK"
var currentLocaleIndex = locales.indexOfFirst { it.language == initialLanguage }
if (currentLocaleIndex < 0) {
Log.e(TAG, "Error looking up language tag '$initialLanguage', falling back to english")
currentLocaleIndex = locales.indexOfFirst { it.language == "en" }
}
val context = this
binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).language
}
override fun onNothingSelected(parent: AdapterView<*>) {
parent.setSelection(locales.indexOfFirst { it.language == getInitialLanguage() })
}
}
binding.composePostLanguageButton.apply {
adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, locales)
setSelection(currentLocaleIndex)
}
}
private fun getInitialLanguage(language: String? = null): String {
return if (language.isNullOrEmpty()) {
// Setting the application ui preference sets the default locale
Locale.getDefault().language
} else {
language
}
}
private fun setupActionBar() {
setSupportActionBar(binding.toolbar)
supportActionBar?.run {
@ -793,6 +830,10 @@ class ComposeActivity :
return length
}
@VisibleForTesting
val selectedLanguage: String?
get() = viewModel.postLanguage
private fun updateVisibleCharactersLeft() {
val remainingLength = maximumTootCharacters - calculateTextLength()
binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
@ -1128,7 +1169,8 @@ class ComposeActivity :
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null
var modifiedInitialState: Boolean? = null,
var language: String? = null,
) : Parcelable
companion object {

View File

@ -68,6 +68,7 @@ class ComposeViewModel @Inject constructor(
private var replyingStatusAuthor: String? = null
private var replyingStatusContent: String? = null
internal var startingText: String? = null
internal var postLanguage: String? = null
private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
@ -261,7 +262,8 @@ class ComposeViewModel @Inject constructor(
mediaDescriptions = mediaDescriptions,
poll = poll.value,
failedToSend = false,
scheduledAt = scheduledAt.value
scheduledAt = scheduledAt.value,
language = postLanguage,
)
}
@ -308,7 +310,8 @@ class ComposeViewModel @Inject constructor(
draftId = draftId,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
mediaProcessed = mediaProcessed
mediaProcessed = mediaProcessed,
language = postLanguage,
)
serviceClient.sendToot(tootToSend)
@ -426,6 +429,7 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.content
postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {

View File

@ -94,7 +94,8 @@ data class ConversationStatusEntity(
val expanded: Boolean,
val collapsed: Boolean,
val muted: Boolean,
val poll: Poll?
val poll: Poll?,
val language: String?,
) {
fun toViewData(): StatusViewData.Concrete {
@ -125,7 +126,8 @@ data class ConversationStatusEntity(
pinned = false,
muted = muted,
poll = poll,
card = null
card = null,
language = language,
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
@ -167,7 +169,8 @@ fun Status.toEntity() =
expanded = false,
collapsed = true,
muted = muted ?: false,
poll = poll
poll = poll,
language = language,
)
fun Conversation.toEntity(accountId: Long, order: Int) =

View File

@ -85,6 +85,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll
poll = poll,
language = status.language,
)
}

View File

@ -61,7 +61,8 @@ class DraftHelper @Inject constructor(
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean,
scheduledAt: String?
scheduledAt: String?,
language: String?,
) = withContext(Dispatchers.IO) {
val externalFilesDir = context.getExternalFilesDir("Tusky")
@ -118,7 +119,8 @@ class DraftHelper @Inject constructor(
attachments = attachments,
poll = poll,
failedToSend = failedToSend,
scheduledAt = scheduledAt
scheduledAt = scheduledAt,
language = language,
)
draftDao.insertOrReplace(draft)

View File

@ -107,7 +107,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility,
scheduledAt = draft.scheduledAt
scheduledAt = draft.scheduledAt,
language = draft.language,
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
@ -145,7 +146,8 @@ class DraftsActivity : BaseActivity(), DraftActionListener {
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility,
scheduledAt = draft.scheduledAt
scheduledAt = draft.scheduledAt,
language = draft.language,
)
startActivity(ComposeActivity.startIntent(this, composeOptions))

View File

@ -365,6 +365,7 @@ public class NotificationHelper {
composeOptions.setReplyingStatusContent(citedText);
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(actionableStatus.getLanguage());
Intent composeIntent = ComposeActivity.startIntent(
context,

View File

@ -216,7 +216,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = actionableStatus.spoilerText,
mentionedUsernames = mentionedUsernames,
replyingStatusAuthor = actionableStatus.account.localUsername,
replyingStatusContent = status.content.toString()
replyingStatusContent = status.content.toString(),
language = actionableStatus.language,
)
)
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
@ -461,7 +462,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
contentWarning = redraftStatus.spoilerText,
mediaAttachments = redraftStatus.attachments,
sensitive = redraftStatus.sensitive,
poll = redraftStatus.poll?.toNewPoll(status.createdAt)
poll = redraftStatus.poll?.toNewPoll(status.createdAt),
language = redraftStatus.language,
)
)
startActivity(intent)

View File

@ -99,7 +99,8 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
contentShowing = false,
pinned = false,
card = null,
repliesCount = 0
repliesCount = 0,
language = null,
)
}
@ -141,7 +142,8 @@ fun Status.toEntity(
contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount
repliesCount = actionableStatus.repliesCount,
language = actionableStatus.language,
)
}
@ -185,7 +187,8 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount
repliesCount = status.repliesCount,
language = status.language,
)
}
val status = if (reblog != null) {
@ -216,6 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
poll = null,
card = null,
repliesCount = status.repliesCount,
language = status.language,
)
} else {
Status(
@ -245,6 +249,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
)
}
return StatusViewData.Concrete(

View File

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 41)
}, version = 42)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -601,4 +601,13 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT");
}
};
public static final Migration MIGRATION_41_42 = new Migration(41, 42) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT");
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT");
}
};
}

View File

@ -40,6 +40,7 @@ data class DraftEntity(
val poll: NewPoll?,
val failedToSend: Boolean,
val scheduledAt: String?,
val language: String?,
)
/**

View File

@ -81,6 +81,7 @@ data class TimelineStatusEntity(
val contentShowing: Boolean,
val pinned: Boolean,
val card: String?,
val language: String?,
)
@Entity(

View File

@ -66,6 +66,7 @@ class AppModule {
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41,
AppDatabase.MIGRATION_41_42,
)
.build()
}

View File

@ -27,7 +27,8 @@ data class DeletedStatus(
val sensitive: Boolean,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date
@SerializedName("created_at") val createdAt: Date,
val language: String?,
) {
fun isEmpty(): Boolean {
return text == null && attachments == null

View File

@ -27,7 +27,8 @@ data class NewStatus(
val sensitive: Boolean,
@SerializedName("media_ids") val mediaIds: List<String>?,
@SerializedName("scheduled_at") val scheduledAt: String?,
val poll: NewPoll?
val poll: NewPoll?,
val language: String?,
)
@Parcelize

View File

@ -48,7 +48,8 @@ data class Status(
val pinned: Boolean?,
val muted: Boolean?,
val poll: Poll?,
val card: Card?
val card: Card?,
val language: String?,
) {
val actionableId: String
@ -130,7 +131,8 @@ data class Status(
sensitive = sensitive,
attachments = attachments,
poll = poll,
createdAt = createdAt
createdAt = createdAt,
language = language,
)
}

View File

@ -154,6 +154,7 @@ public abstract class SFragment extends Fragment implements Injectable {
composeOptions.setMentionedUsernames(mentionedUsernames);
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString());
composeOptions.setLanguage(actionableStatus.getLanguage());
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
getActivity().startActivity(intent);
@ -425,6 +426,7 @@ public abstract class SFragment extends Fragment implements Injectable {
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
composeOptions.setSensitive(deletedStatus.getSensitive());
composeOptions.setModifiedInitialState(true);
composeOptions.setLanguage(deletedStatus.getLanguage());
if (deletedStatus.getPoll() != null) {
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
}

View File

@ -98,7 +98,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
draftId = -1,
idempotencyKey = randomAlphanumericString(16),
retries = 0,
mediaProcessed = mutableListOf()
mediaProcessed = mutableListOf(),
null,
)
)

View File

@ -156,7 +156,8 @@ class SendStatusService : Service(), Injectable {
statusToSend.sensitive,
statusToSend.mediaIds,
statusToSend.scheduledAt,
statusToSend.poll
statusToSend.poll,
statusToSend.language,
)
mastodonApi.createStatus(
@ -259,7 +260,8 @@ class SendStatusService : Service(), Injectable {
mediaDescriptions = status.mediaDescriptions,
poll = status.poll,
failedToSend = true,
scheduledAt = status.scheduledAt
scheduledAt = status.scheduledAt,
language = status.language,
)
}
@ -366,5 +368,6 @@ data class StatusToSend(
val draftId: Int,
val idempotencyKey: String,
var retries: Int,
val mediaProcessed: MutableList<Boolean>
val mediaProcessed: MutableList<Boolean>,
val language: String?,
) : Parcelable

View File

@ -22,6 +22,19 @@
tools:ignore="ContentDescription" />
<!--content description will be set in code -->
<Spinner
android:id="@+id/composePostLanguageButton"
style="@style/TuskyImageButton"
android:layout_width="56dp"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:padding="0dp"
android:contentDescription="@string/description_post_language"
android:textColor="?android:attr/textColorTertiary"
android:textSize="?attr/status_text_large"
android:textStyle="bold"
app:tooltipText="@string/description_post_language" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/atButton"
style="@style/TuskyImageButton"

View File

@ -515,6 +515,7 @@
<string name="description_poll">
Poll with choices: %1$s, %2$s, %3$s, %4$s; %5$s
</string>
<string name="description_post_language">Post language</string>
<string name="hint_list_name">List name</string>

View File

@ -88,7 +88,8 @@ class BottomSheetActivityTest {
pinned = false,
muted = false,
poll = null,
card = null
card = null,
language = null,
)
private val statusSingle = Single.just(SearchResult(emptyList(), listOf(status), emptyList()))

View File

@ -48,6 +48,7 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem
import java.util.Locale
/**
* Created by charlag on 3/7/18.
@ -444,6 +445,19 @@ class ComposeActivityTest {
assertEquals(selectionEnd + insertText.length, editor.selectionEnd)
}
@Test
fun whenNoLanguageIsGiven_defaultLanguageIsSelected() {
assertEquals(Locale.getDefault().language, activity.selectedLanguage)
}
@Test
fun languageGivenInComposeOptionsIsRespected() {
val language = "no"
composeOptions = ComposeActivity.ComposeOptions(language = language)
setupActivity()
assertEquals(language, activity.selectedLanguage)
}
private fun clickUp() {
val menuItem = RoboMenuItem(android.R.id.home)
activity.onOptionsItemSelected(menuItem)

View File

@ -260,7 +260,8 @@ class FilterTest {
ownVotes = null
)
} else null,
card = null
card = null,
language = null,
)
}
}

View File

@ -51,7 +51,8 @@ fun mockStatus(
pinned = false,
muted = false,
poll = null,
card = null
card = null,
language = null,
)
fun mockStatusViewData(

View File

@ -463,6 +463,7 @@ class TimelineDaoTest {
contentShowing = true,
pinned = false,
card = card,
language = null,
)
return Triple(status, author, reblogAuthor)
}