Handle even more instance defaults (#2612)

* handle media size instance limits

* remove unused attributes from Instance entity

* support max_media_attachments

* support pleroma field limits, remove max_bio_chars support

* improve field input margin

* fix tests

* MAX_ACCOUNT_FIELDS -> DEFAULT_MAX_ACCOUNT_FIELDS

* improve "add field" button behavior

* fix copy paste mistake in AccountFieldEditAdapter

* refactor sendStatus to be a suspending function
This commit is contained in:
Konrad Pozniak 2022-07-26 20:24:50 +02:00 committed by GitHub
parent 25f637f0a8
commit 1b6a0908f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1219 additions and 308 deletions

View File

@ -0,0 +1,929 @@
{
"formatVersion": 1,
"database": {
"version": 40,
"identityHash": "0423fb3f7d09db5f12023f2f4e7297b5",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `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, 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
}
],
"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, 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
}
],
"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, '0423fb3f7d09db5f12023f2f4e7297b5')"
]
}
}

View File

@ -28,6 +28,7 @@ import android.widget.ImageView
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
@ -37,6 +38,7 @@ import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options import com.canhub.cropper.options
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
@ -50,6 +52,7 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class EditProfileActivity : BaseActivity(), Injectable { class EditProfileActivity : BaseActivity(), Injectable {
@ -58,8 +61,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
const val AVATAR_SIZE = 400 const val AVATAR_SIZE = 400
const val HEADER_WIDTH = 1500 const val HEADER_WIDTH = 1500
const val HEADER_HEIGHT = 500 const val HEADER_HEIGHT = 500
private const val MAX_ACCOUNT_FIELDS = 4
} }
@Inject @Inject
@ -71,6 +72,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
private val accountFieldEditAdapter = AccountFieldEditAdapter() private val accountFieldEditAdapter = AccountFieldEditAdapter()
private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS
private enum class PickType { private enum class PickType {
AVATAR, AVATAR,
HEADER HEADER
@ -112,7 +115,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.addFieldButton.setOnClickListener { binding.addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField() accountFieldEditAdapter.addField()
if (accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) { if (accountFieldEditAdapter.itemCount >= maxAccountFields) {
it.isVisible = false it.isVisible = false
} }
@ -134,7 +137,8 @@ class EditProfileActivity : BaseActivity(), Injectable {
binding.lockedCheckBox.isChecked = me.locked binding.lockedCheckBox.isChecked = me.locked
accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList()) accountFieldEditAdapter.setFields(me.source?.fields ?: emptyList())
binding.addFieldButton.isEnabled = me.source?.fields?.size ?: 0 < MAX_ACCOUNT_FIELDS binding.addFieldButton.isVisible =
(me.source?.fields?.size ?: 0) < maxAccountFields
if (viewModel.avatarData.value == null) { if (viewModel.avatarData.value == null) {
Glide.with(this) Glide.with(this)
@ -165,13 +169,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
} }
} }
viewModel.obtainInstance() lifecycleScope.launch {
viewModel.instanceData.observe(this) { result -> viewModel.instanceData.collect { instanceInfo ->
if (result is Success) { maxAccountFields = instanceInfo.maxFields
val instance = result.data accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength)
if (instance?.maxBioChars != null && instance.maxBioChars > 0) { binding.addFieldButton.isVisible =
binding.noteEditTextLayout.counterMaxLength = instance.maxBioChars accountFieldEditAdapter.itemCount < maxAccountFields
}
} }
} }

View File

@ -27,6 +27,8 @@ import com.keylesspalace.tusky.util.BindingHolder
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() { class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
private val fieldData = mutableListOf<MutableStringPair>() private val fieldData = mutableListOf<MutableStringPair>()
private var maxNameLength: Int? = null
private var maxValueLength: Int? = null
fun setFields(fields: List<StringField>) { fun setFields(fields: List<StringField>) {
fieldData.clear() fieldData.clear()
@ -41,6 +43,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
notifyDataSetChanged() notifyDataSetChanged()
} }
fun setFieldLimits(maxNameLength: Int?, maxValueLength: Int?) {
this.maxNameLength = maxNameLength
this.maxValueLength = maxValueLength
notifyDataSetChanged()
}
fun getFieldData(): List<StringField> { fun getFieldData(): List<StringField> {
return fieldData.map { return fieldData.map {
StringField(it.first, it.second) StringField(it.first, it.second)
@ -60,10 +68,20 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
} }
override fun onBindViewHolder(holder: BindingHolder<ItemEditFieldBinding>, position: Int) { override fun onBindViewHolder(holder: BindingHolder<ItemEditFieldBinding>, position: Int) {
holder.binding.accountFieldName.setText(fieldData[position].first) holder.binding.accountFieldNameText.setText(fieldData[position].first)
holder.binding.accountFieldValue.setText(fieldData[position].second) holder.binding.accountFieldValueText.setText(fieldData[position].second)
holder.binding.accountFieldName.addTextChangedListener(object : TextWatcher { holder.binding.accountFieldNameTextLayout.isCounterEnabled = maxNameLength != null
maxNameLength?.let {
holder.binding.accountFieldNameTextLayout.counterMaxLength = it
}
holder.binding.accountFieldValueTextLayout.isCounterEnabled = maxValueLength != null
maxValueLength?.let {
holder.binding.accountFieldValueTextLayout.counterMaxLength = it
}
holder.binding.accountFieldNameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) { override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].first = newText.toString() fieldData[holder.bindingAdapterPosition].first = newText.toString()
} }
@ -73,7 +91,7 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
}) })
holder.binding.accountFieldValue.addTextChangedListener(object : TextWatcher { holder.binding.accountFieldValueText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(newText: Editable) { override fun afterTextChanged(newText: Editable) {
fieldData[holder.bindingAdapterPosition].second = newText.toString() fieldData[holder.bindingAdapterPosition].second = newText.toString()
} }

View File

@ -52,7 +52,6 @@ import androidx.core.view.ContentInfoCompat
import androidx.core.view.OnReceiveContentListener import androidx.core.view.OnReceiveContentListener
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -85,8 +84,6 @@ import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.highlightSpans
@ -95,11 +92,13 @@ import com.keylesspalace.tusky.util.onTextChanged
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.withLifecycleContext
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
@ -138,8 +137,7 @@ class ComposeActivity :
private val binding by viewBinding(ActivityComposeBinding::inflate) private val binding by viewBinding(ActivityComposeBinding::inflate)
private val maxUploadMediaNumber = 4 private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS
private var mediaCount = 0
private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) { if (success) {
@ -147,7 +145,7 @@ class ComposeActivity :
} }
} }
private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris ->
if (mediaCount + uris.size > maxUploadMediaNumber) { if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) {
Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
} else { } else {
uris.forEach { uri -> uris.forEach { uri ->
@ -224,8 +222,8 @@ class ComposeActivity :
binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.adapter = mediaAdapter
binding.composeMediaPreviewBar.itemAnimator = null binding.composeMediaPreviewBar.itemAnimator = null
subscribeToUpdates(mediaAdapter)
setupButtons() setupButtons()
subscribeToUpdates(mediaAdapter)
photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY) photoUploadUri = savedInstanceState?.getParcelable(PHOTO_UPLOAD_URI_KEY)
@ -363,36 +361,48 @@ class ComposeActivity :
} }
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
withLifecycleContext { lifecycleScope.launch {
viewModel.instanceInfo.observe { instanceData -> viewModel.instanceInfo.collect { instanceData ->
maximumTootCharacters = instanceData.maxChars maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl charactersReservedPerUrl = instanceData.charactersReservedPerUrl
maxUploadMediaNumber = instanceData.maxMediaAttachments
updateVisibleCharactersLeft() updateVisibleCharactersLeft()
} }
viewModel.emoji.observe { emoji -> setEmojiList(emoji) } }
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
lifecycleScope.launch {
viewModel.emoji.collect(::setEmojiList)
}
lifecycleScope.launch {
viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive ->
updateSensitiveMediaToggle(markSensitive, showContentWarning) updateSensitiveMediaToggle(markSensitive, showContentWarning)
showContentWarning(showContentWarning) showContentWarning(showContentWarning)
}.subscribe() }.collect()
viewModel.statusVisibility.observe { visibility -> }
setStatusVisibility(visibility)
}
lifecycleScope.launch {
viewModel.media.collect { media ->
mediaAdapter.submitList(media)
if (media.size != mediaCount) {
mediaCount = media.size
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
}
}
}
viewModel.poll.observe { poll -> lifecycleScope.launch {
viewModel.statusVisibility.collect(::setStatusVisibility)
}
lifecycleScope.launch {
viewModel.media.collect { media ->
mediaAdapter.submitList(media)
binding.composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value)
}
}
lifecycleScope.launch {
viewModel.poll.collect { poll ->
binding.pollPreview.visible(poll != null) binding.pollPreview.visible(poll != null)
poll?.let(binding.pollPreview::setPoll) poll?.let(binding.pollPreview::setPoll)
} }
viewModel.scheduledAt.observe { scheduledAt -> }
lifecycleScope.launch {
viewModel.scheduledAt.collect { scheduledAt ->
if (scheduledAt == null) { if (scheduledAt == null) {
binding.composeScheduleView.resetSchedule() binding.composeScheduleView.resetSchedule()
} else { } else {
@ -400,22 +410,30 @@ class ComposeActivity :
} }
updateScheduleButton() updateScheduleButton()
} }
combineOptionalLiveData(viewModel.media.asLiveData(), viewModel.poll) { media, poll -> }
lifecycleScope.launch {
viewModel.media.combine(viewModel.poll) { media, poll ->
val active = poll == null && val active = poll == null &&
media!!.size != 4 && media.size < maxUploadMediaNumber &&
(media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE)
enableButton(binding.composeAddMediaButton, active, active) enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty()) enablePollButton(media.isEmpty())
}.subscribe() }.collect()
viewModel.uploadError.observe { throwable -> }
Log.w(TAG, "media upload failed", throwable)
lifecycleScope.launch {
viewModel.uploadError.collect { throwable ->
if (throwable is UploadServerError) { if (throwable is UploadServerError) {
displayTransientError(throwable.errorMessage) displayTransientError(throwable.errorMessage)
} else { } else {
displayTransientError(R.string.error_media_upload_sending) displayTransientError(R.string.error_media_upload_sending)
} }
} }
viewModel.setupComplete.observe { }
lifecycleScope.launch {
viewModel.setupComplete.collect {
// Focus may have changed during view model setup, ensure initial focus is on the edit field // Focus may have changed during view model setup, ensure initial focus is on the edit field
binding.composeEditField.requestFocus() binding.composeEditField.requestFocus()
} }
@ -711,13 +729,17 @@ class ComposeActivity :
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
private fun openPollDialog() { private fun openPollDialog() = lifecycleScope.launch {
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
val instanceParams = viewModel.instanceInfo.value!! val instanceParams = viewModel.instanceInfo.first()
showAddPollDialog( showAddPollDialog(
this, viewModel.poll.value, instanceParams.pollMaxOptions, context = this@ComposeActivity,
instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, poll = viewModel.poll.value,
viewModel::updatePoll maxOptionCount = instanceParams.pollMaxOptions,
maxOptionLength = instanceParams.pollMaxLength,
minDuration = instanceParams.pollMinDuration,
maxDuration = instanceParams.pollMaxDuration,
onUpdatePoll = viewModel::updatePoll
) )
} }
@ -768,7 +790,7 @@ class ComposeActivity :
} }
} }
var length = binding.composeEditField.length() - offset var length = binding.composeEditField.length() - offset
if (viewModel.showContentWarning.value!!) { if (viewModel.showContentWarning.value) {
length += binding.composeContentWarningField.length() length += binding.composeContentWarningField.length()
} }
return length return length
@ -822,7 +844,7 @@ class ComposeActivity :
enableButtons(false) enableButtons(false)
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
var spoilerText = "" var spoilerText = ""
if (viewModel.showContentWarning.value!!) { if (viewModel.showContentWarning.value) {
spoilerText = binding.composeContentWarningField.text.toString() spoilerText = binding.composeContentWarningField.text.toString()
} }
val characterCount = calculateTextLength() val characterCount = calculateTextLength()
@ -837,9 +859,8 @@ class ComposeActivity :
) )
} }
viewModel.sendStatus(contentText, spoilerText).observe( lifecycleScope.launch {
this viewModel.sendStatus(contentText, spoilerText)
) {
finishingUploadDialog?.dismiss() finishingUploadDialog?.dismiss()
deleteDraftAndFinish() deleteDraftAndFinish()
} }

View File

@ -18,10 +18,7 @@ package com.keylesspalace.tusky.components.compose
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
@ -38,30 +35,34 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@OptIn(FlowPreview::class)
class ComposeViewModel @Inject constructor( class ComposeViewModel @Inject constructor(
private val api: MastodonApi, private val api: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper, private val draftHelper: DraftHelper,
private val instanceInfoRepo: InstanceInfoRepository instanceInfoRepo: InstanceInfoRepository
) : ViewModel() { ) : ViewModel() {
private var replyingStatusAuthor: String? = null private var replyingStatusAuthor: String? = null
@ -76,40 +77,32 @@ class ComposeViewModel @Inject constructor(
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false private var modifiedInitialState: Boolean = false
val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData() val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData() val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
val markMediaAsSensitive = .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) val markMediaAsSensitive: MutableStateFlow<Boolean> =
val showContentWarning = mutableLiveData(false) MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val setupComplete = mutableLiveData(false)
val poll: MutableLiveData<NewPoll?> = mutableLiveData(null) val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null) val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val setupComplete: MutableStateFlow<Boolean> = MutableStateFlow(false)
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList()) val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableLiveData<Throwable>() val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val mediaToJob = mutableMapOf<Int, Job>() private val mediaToJob = mutableMapOf<Int, Job>()
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
// Used in ComposeActivity to pass state to result function when cropImage contract inflight // Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null var cropImageItemOld: QueuedMedia? = null
init {
viewModelScope.launch {
emoji.postValue(instanceInfoRepo.getEmojis())
}
viewModelScope.launch {
instanceInfo.postValue(instanceInfoRepo.getInstanceInfo())
}
}
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) { suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
try { try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri) val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = media.value val mediaItems = media.value
if (type != QueuedMedia.Type.IMAGE && if (type != QueuedMedia.Type.IMAGE &&
mediaItems.isNotEmpty() && mediaItems.isNotEmpty() &&
@ -157,10 +150,10 @@ class ComposeViewModel @Inject constructor(
mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaToJob[mediaItem.localId] = viewModelScope.launch {
mediaUploader mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem, instanceInfo.first())
.catch { error -> .catch { error ->
media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } } media.update { mediaValue -> mediaValue.filter { it.localId != mediaItem.localId } }
uploadError.postValue(error) uploadError.emit(error)
} }
.collect { event -> .collect { event ->
val item = media.value.find { it.localId == mediaItem.localId } val item = media.value.find { it.localId == mediaItem.localId }
@ -216,7 +209,7 @@ class ComposeViewModel @Inject constructor(
startingText?.startsWith(content.toString()) ?: false startingText?.startsWith(content.toString()) ?: false
) )
val contentWarningChanged = showContentWarning.value!! && val contentWarningChanged = showContentWarning.value &&
!contentWarning.isNullOrEmpty() && !contentWarning.isNullOrEmpty() &&
!startingContentWarning.startsWith(contentWarning.toString()) !startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = media.value.isNotEmpty() val mediaChanged = media.value.isNotEmpty()
@ -259,8 +252,8 @@ class ComposeViewModel @Inject constructor(
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
content = content, content = content,
contentWarning = contentWarning, contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value!!, sensitive = markMediaAsSensitive.value,
visibility = statusVisibility.value!!, visibility = statusVisibility.value,
mediaUris = mediaUris, mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
poll = poll.value, poll = poll.value,
@ -271,38 +264,34 @@ class ComposeViewModel @Inject constructor(
/** /**
* Send status to the server. * Send status to the server.
* Uses current state plus provided arguments. * Uses current state plus provided arguments.
* @return LiveData which will signal once the screen can be closed or null if there are errors
*/ */
fun sendStatus( suspend fun sendStatus(
content: String, content: String,
spoilerText: String spoilerText: String
): LiveData<Unit> { ) {
val deletionObservable = if (isEditingScheduledToot) { if (!scheduledTootId.isNullOrEmpty()) {
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { } api.deleteScheduledStatus(scheduledTootId!!)
} else { }
Observable.just(Unit)
}.toLiveData()
val sendFlow = media media
.filter { items -> items.all { it.uploadPercent == -1 } } .filter { items -> items.all { it.uploadPercent == -1 } }
.map { .first {
val mediaIds: MutableList<String> = mutableListOf() val mediaIds: MutableList<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf() val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf() val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = mutableListOf() val mediaProcessed: MutableList<Boolean> = mutableListOf()
for (item in media.value) { media.value.forEach { item ->
mediaIds.add(item.id!!) mediaIds.add(item.id!!)
mediaUris.add(item.uri) mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "") mediaDescriptions.add(item.description ?: "")
mediaProcessed.add(false) mediaProcessed.add(false)
} }
val tootToSend = StatusToSend( val tootToSend = StatusToSend(
text = content, text = content,
warningText = spoilerText, warningText = spoilerText,
visibility = statusVisibility.value!!.serverString(), visibility = statusVisibility.value.serverString(),
sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value),
mediaIds = mediaIds, mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() }, mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
@ -319,9 +308,8 @@ class ComposeViewModel @Inject constructor(
) )
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
true
} }
return combineLiveData(deletionObservable, sendFlow.asLiveData()) { _, _ -> }
} }
suspend fun updateDescription(localId: Int, description: String): Boolean { suspend fun updateDescription(localId: Int, description: String): Boolean {
@ -369,7 +357,7 @@ class ComposeViewModel @Inject constructor(
}) })
} }
':' -> { ':' -> {
val emojiList = emoji.value ?: return emptyList() val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList()
val incomplete = token.substring(1) val incomplete = token.substring(1)
return emojiList.filter { emoji -> return emojiList.filter { emoji ->
@ -389,7 +377,7 @@ class ComposeViewModel @Inject constructor(
fun setup(composeOptions: ComposeActivity.ComposeOptions?) { fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
if (setupComplete.value == true) { if (setupComplete.value) {
return return
} }
@ -476,8 +464,6 @@ class ComposeViewModel @Inject constructor(
} }
} }
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
/** /**
* Thrown when trying to add an image when video is already present or the other way around * Thrown when trying to add an image when video is already present or the other way around
*/ */

View File

@ -27,6 +27,7 @@ import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.ProgressRequestBody import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
@ -82,10 +83,10 @@ class MediaUploader @Inject constructor(
) { ) {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> { fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
return flow { return flow {
if (shouldResizeMedia(media)) { if (shouldResizeMedia(media, instanceInfo)) {
emit(downsize(media)) emit(downsize(media, instanceInfo))
} else { } else {
emit(media) emit(media)
} }
@ -94,7 +95,7 @@ class MediaUploader @Inject constructor(
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
} }
fun prepareMedia(inUri: Uri): PreparedMedia { fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
var mediaSize = MEDIA_SIZE_UNKNOWN var mediaSize = MEDIA_SIZE_UNKNOWN
var uri = inUri var uri = inUri
val mimeType: String? val mimeType: String?
@ -164,7 +165,7 @@ class MediaUploader @Inject constructor(
if (mimeType != null) { if (mimeType != null) {
return when (mimeType.substring(0, mimeType.indexOf('/'))) { return when (mimeType.substring(0, mimeType.indexOf('/'))) {
"video" -> { "video" -> {
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { if (mediaSize > instanceInfo.videoSizeLimit) {
throw VideoSizeException() throw VideoSizeException()
} }
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
@ -173,7 +174,7 @@ class MediaUploader @Inject constructor(
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
} }
"audio" -> { "audio" -> {
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { if (mediaSize > instanceInfo.videoSizeLimit) {
throw AudioSizeException() throw AudioSizeException()
} }
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
@ -239,22 +240,18 @@ class MediaUploader @Inject constructor(
} }
} }
private fun downsize(media: QueuedMedia): QueuedMedia { private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia {
val file = createNewImageFile(context) val file = createNewImageFile(context)
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file) downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file)
return media.copy(uri = file.toUri(), mediaSize = file.length()) return media.copy(uri = file.toUri(), mediaSize = file.length())
} }
private fun shouldResizeMedia(media: QueuedMedia): Boolean { private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean {
return media.type == QueuedMedia.Type.IMAGE && return media.type == QueuedMedia.Type.IMAGE &&
(media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT) (media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
} }
private companion object { private companion object {
private const val TAG = "MediaUploader" private const val TAG = "MediaUploader"
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
} }
} }

View File

@ -21,5 +21,12 @@ data class InstanceInfo(
val pollMaxLength: Int, val pollMaxLength: Int,
val pollMinDuration: Int, val pollMinDuration: Int,
val pollMaxDuration: Int, val pollMaxDuration: Int,
val charactersReservedPerUrl: Int val charactersReservedPerUrl: Int,
val videoSizeLimit: Int,
val imageSizeLimit: Int,
val imageMatrixLimit: Int,
val maxMediaAttachments: Int,
val maxFields: Int,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
) )

View File

@ -69,7 +69,14 @@ class InstanceInfoRepository @Inject constructor(
minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration,
maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration,
charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl,
version = instance.version version = instance.version,
videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit,
imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit,
imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit,
maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments,
maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields,
maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength,
maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength
) )
dao.insertOrReplace(instanceEntity) dao.insertOrReplace(instanceEntity)
instanceEntity instanceEntity
@ -85,7 +92,14 @@ class InstanceInfoRepository @Inject constructor(
pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION,
pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION,
charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL,
videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT,
imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT,
imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT,
maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS,
maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS,
maxFieldNameLength = instanceInfo?.maxFieldNameLength,
maxFieldValueLength = instanceInfo?.maxFieldValueLength
) )
} }
} }
@ -99,7 +113,14 @@ class InstanceInfoRepository @Inject constructor(
private const val DEFAULT_MIN_POLL_DURATION = 300 private const val DEFAULT_MIN_POLL_DURATION = 300
private const val DEFAULT_MAX_POLL_DURATION = 604800 private const val DEFAULT_MAX_POLL_DURATION = 604800
private const val DEFAULT_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
private const val DEFAULT_IMAGE_SIZE_LIMIT = 10485760 // 10MiB
private const val DEFAULT_IMAGE_MATRIX_LIMIT = 16777216 // 4096^2 Pixels
// Mastodon only counts URLs as this long in terms of status character limits // Mastodon only counts URLs as this long in terms of status character limits
const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23
const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4
const val DEFAULT_MAX_ACCOUNT_FIELDS = 4
} }
} }

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 39) }, version = 40)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -581,4 +581,17 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
} }
}; };
public static final Migration MIGRATION_39_40 = new Migration(39, 40) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER");
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER");
}
};
} }

View File

@ -31,7 +31,14 @@ data class InstanceEntity(
val minPollDuration: Int?, val minPollDuration: Int?,
val maxPollDuration: Int?, val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?, val charactersReservedPerUrl: Int?,
val version: String? val version: String?,
val videoSizeLimit: Int?,
val imageSizeLimit: Int?,
val imageMatrixLimit: Int?,
val maxMediaAttachments: Int?,
val maxFields: Int?,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -48,5 +55,12 @@ data class InstanceInfoEntity(
val minPollDuration: Int?, val minPollDuration: Int?,
val maxPollDuration: Int?, val maxPollDuration: Int?,
val charactersReservedPerUrl: Int?, val charactersReservedPerUrl: Int?,
val version: String? val version: String?,
val videoSizeLimit: Int?,
val imageSizeLimit: Int?,
val imageMatrixLimit: Int?,
val maxMediaAttachments: Int?,
val maxFields: Int?,
val maxFieldNameLength: Int?,
val maxFieldValueLength: Int?
) )

View File

@ -65,7 +65,7 @@ class AppModule {
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, 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_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39 AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40
) )
.build() .build()
} }

View File

@ -19,19 +19,20 @@ import com.google.gson.annotations.SerializedName
data class Instance( data class Instance(
val uri: String, val uri: String,
val title: String, // val title: String,
val description: String, // val description: String,
val email: String, // val email: String,
val version: String, val version: String,
val urls: Map<String, String>, // val urls: Map<String, String>,
val stats: Map<String, Int>?, // val stats: Map<String, Int>?,
val thumbnail: String?, // val thumbnail: String?,
val languages: List<String>, // val languages: List<String>,
@SerializedName("contact_account") val contactAccount: Account, // @SerializedName("contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?, @SerializedName("max_toot_chars") val maxTootChars: Int?,
@SerializedName("max_bio_chars") val maxBioChars: Int?,
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?,
val configuration: InstanceConfiguration?, val configuration: InstanceConfiguration?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?,
val pleroma: PleromaConfiguration?
) { ) {
override fun hashCode(): Int { override fun hashCode(): Int {
return uri.hashCode() return uri.hashCode()
@ -74,3 +75,17 @@ data class MediaAttachmentConfiguration(
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?,
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, @SerializedName("video_matrix_limit") val videoMatrixLimit: Int?,
) )
data class PleromaConfiguration(
val metadata: PleromaMetadata?
)
data class PleromaMetadata(
@SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits
)
data class PleromaFieldLimits(
@SerializedName("max_fields") val maxFields: Int?,
@SerializedName("name_length") val nameLength: Int?,
@SerializedName("value_length") val valueLength: Int?
)

View File

@ -1,98 +0,0 @@
/* 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.util
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.LiveDataReactiveStreams
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.Transformations
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
inline fun <X, Y> LiveData<X>.map(crossinline mapFunction: (X) -> Y): LiveData<Y> =
Transformations.map(this) { input -> mapFunction(input) }
inline fun <X, Y> LiveData<X>.switchMap(
crossinline switchMapFunction: (X) -> LiveData<Y>
): LiveData<Y> = Transformations.switchMap(this) { input -> switchMapFunction(input) }
inline fun <X> LiveData<X>.filter(crossinline predicate: (X) -> Boolean): LiveData<X> {
val liveData = MediatorLiveData<X>()
liveData.addSource(this) { value ->
if (predicate(value)) {
liveData.value = value
}
}
return liveData
}
fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) =
LifecycleContext(this).apply(body)
class LifecycleContext(val lifecycleOwner: LifecycleOwner) {
inline fun <T> LiveData<T>.observe(crossinline observer: (T) -> Unit) =
this.observe(lifecycleOwner, Observer { observer(it) })
/**
* Just hold a subscription,
*/
fun <T> LiveData<T>.subscribe() =
this.observe(lifecycleOwner, Observer { })
}
/**
* Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns
* [LiveData] with value set to the result of calling [combiner] with value of both.
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
*/
fun <A, B, R> combineLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A, B) -> R): LiveData<R> {
val liveData = MediatorLiveData<R>()
liveData.addSource(a) {
if (a.value != null && b.value != null) {
liveData.value = combiner(a.value!!, b.value!!)
}
}
liveData.addSource(b) {
if (a.value != null && b.value != null) {
liveData.value = combiner(a.value!!, b.value!!)
}
}
return liveData
}
/**
* Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b]
* after either changes. Doesn't check if either has value.
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
*/
fun <A, B, R> combineOptionalLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A?, B?) -> R): LiveData<R> {
val liveData = MediatorLiveData<R>()
liveData.addSource(a) {
liveData.value = combiner(a.value, b.value)
}
liveData.addSource(b) {
liveData.value = combiner(a.value, b.value)
}
return liveData
}
fun <T> Single<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable())
fun <T> Observable<T>.toLiveData(
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST
) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST))

View File

@ -24,8 +24,9 @@ import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Error
@ -34,6 +35,11 @@ import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -49,14 +55,18 @@ private const val AVATAR_FILE_NAME = "avatar.png"
class EditProfileViewModel @Inject constructor( class EditProfileViewModel @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val eventHub: EventHub, private val eventHub: EventHub,
private val application: Application private val application: Application,
private val instanceInfoRepo: InstanceInfoRepository
) : ViewModel() { ) : ViewModel() {
val profileData = MutableLiveData<Resource<Account>>() val profileData = MutableLiveData<Resource<Account>>()
val avatarData = MutableLiveData<Uri>() val avatarData = MutableLiveData<Uri>()
val headerData = MutableLiveData<Uri>() val headerData = MutableLiveData<Uri>()
val saveData = MutableLiveData<Resource<Nothing>>() val saveData = MutableLiveData<Resource<Nothing>>()
val instanceData = MutableLiveData<Resource<Instance>>()
@OptIn(FlowPreview::class)
val instanceData: Flow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
private var oldProfileData: Account? = null private var oldProfileData: Account? = null
@ -186,19 +196,4 @@ class EditProfileViewModel @Inject constructor(
private fun getCacheFileForName(filename: String): File { private fun getCacheFileForName(filename: String): File {
return File(application.cacheDir, filename) return File(application.cacheDir, filename)
} }
fun obtainInstance() = viewModelScope.launch {
if (instanceData.value == null || instanceData.value is Error) {
instanceData.postValue(Loading())
mastodonApi.getInstance().fold(
{ instance ->
instanceData.postValue(Success(instance))
},
{
instanceData.postValue(Error())
}
)
}
}
} }

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
@ -17,23 +18,40 @@
android:paddingEnd="16dp" android:paddingEnd="16dp"
android:paddingBottom="8dp"> android:paddingBottom="8dp">
<androidx.emoji2.widget.EmojiEditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountFieldName" style="@style/TuskyTextInput"
android:id="@+id/accountFieldNameTextLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:hint="@string/profile_metadata_label_label" android:hint="@string/profile_metadata_label_label"
android:textColorHint="?android:attr/textColorTertiary" app:counterTextColor="?android:textColorTertiary">
android:textSize="?attr/status_text_medium" />
<androidx.emoji2.widget.EmojiEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/accountFieldValue" android:id="@+id/accountFieldNameText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/TuskyTextInput"
android:id="@+id/accountFieldValueTextLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:hint="@string/profile_metadata_content_label" android:hint="@string/profile_metadata_content_label"
android:lineSpacingMultiplier="1.1" app:counterTextColor="?android:textColorTertiary">
android:textColorHint="?android:attr/textColorTertiary"
android:textSize="?attr/status_text_medium" /> <com.google.android.material.textfield.TextInputEditText
android:id="@+id/accountFieldValueText"
android:lineSpacingMultiplier="1.1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View File

@ -30,7 +30,6 @@ import com.keylesspalace.tusky.db.EmojisEntity
import com.keylesspalace.tusky.db.InstanceDao import com.keylesspalace.tusky.db.InstanceDao
import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.db.InstanceInfoEntity
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.InstanceConfiguration
import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration
@ -48,8 +47,6 @@ import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.fakes.RoboMenuItem import org.robolectric.fakes.RoboMenuItem
import java.util.Date
import kotlin.collections.HashMap
/** /**
* Created by charlag on 3/7/18. * Created by charlag on 3/7/18.
@ -110,7 +107,7 @@ class ComposeActivityTest {
val instanceDaoMock: InstanceDao = mock { val instanceDaoMock: InstanceDao = mock {
onBlocking { getInstanceInfo(any()) } doReturn onBlocking { getInstanceInfo(any()) } doReturn
InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null) InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null, null, null, null, null, null, null, null)
onBlocking { getEmojiInfo(any()) } doReturn onBlocking { getEmojiInfo(any()) } doReturn
EmojisEntity(instanceDomain, emptyList()) EmojisEntity(instanceDomain, emptyList())
} }
@ -461,38 +458,13 @@ class ComposeActivityTest {
private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance { private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance {
return Instance( return Instance(
"https://example.token", uri = "https://example.token",
"Example dot Token", version = "2.6.3",
"Example instance for testing", maxTootChars = maximumLegacyTootCharacters,
"admin@example.token", pollConfiguration = null,
"2.6.3", configuration = configuration,
HashMap(), maxMediaAttachments = null,
null, pleroma = null
null,
listOf("en"),
Account(
id = "1",
localUsername = "admin",
username = "admin",
displayName = "admin",
createdAt = Date(),
note = "",
url = "https://example.token",
avatar = "",
header = "",
locked = false,
statusesCount = 0,
followersCount = 0,
followingCount = 0,
source = null,
bot = false,
emojis = emptyList(),
fields = emptyList(),
),
maximumLegacyTootCharacters,
null,
null,
configuration,
) )
} }