Add UI for image-attachment "focus" (#2620)

* Attempt-zero implementation of a "focus" feature for image attachments. Choose "Set focus" in the attachment menu, tap once to select focus point (no visual feedback currently), tap "OK". Works in tests.

* Remove code duplication between 'update description' and 'update focus'

* Fix ktlint/bitrise failures

* Make updateMediaItem private

* When focus is set on a post attachment the preview focuses correctly. ProgressImageView now inherits from MediaPreviewImageView.

* Replace use of PointF for Focus where focus is represented, fix ktlint

* Substitute 'focus' for 'focus point' in strings

* First attempt draw focus point. Only updates on initial load. Modeled on code from RoundedCorners builtin from Glide

* Redraw focus after each tap

* Dark curtain where focus isn't (now looks like mastosoc)

* Correct ktlint for FocusDialog

* draft: switch to overlay for focus indicator

* Draw focus circle, but ImageView and FocusIndicatorView seem to share a single canvas

* Switch focus circle to path approach

* Correctly scale, save and load focuses. Clamp to visible area. Focus editor looks and feels right

* ktlint fixes and comments

* Focus indicator drawing should use device-independent pixels

* Shrink focus window when it gets unattractively tall (no linting, misbehaves on wide aspect ratio screens)

* Correct max-height behavior for screens in landscape mode

* Focus attachment result is are flipped on x axis; fix this

* Correctly thread focus through on scheduled posts, redrafted posts, and drafts (but draft focus is lost on post)

* More focus ktlint fixes

* Fix specific case where a draft is given a focus, then deleted, then posted in that order

* Fix accidental file change in focus PR

* ktLint fix

* Fix property style warnings in focus

* Fix remaining style warnings from focus PR

Co-authored-by: Conny Duck <k.pozniak@gmx.at>
This commit is contained in:
mcclure 2022-09-21 14:28:06 -04:00 committed by GitHub
parent 5d09a67b52
commit 7684f06938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 358 additions and 26 deletions

View File

@ -69,6 +69,7 @@ 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.CaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
@ -171,6 +172,7 @@ class ComposeActivity :
uriNew,
size,
itemOld.description,
null, // Intentionally reset focus when cropping
itemOld
)
}
@ -217,6 +219,11 @@ class ComposeActivity :
CaptionDialog.newInstance(item.localId, item.description, item.uri)
.show(supportFragmentManager, "caption_dialog")
},
onAddFocus = { item ->
makeFocusDialog(item.focus, item.uri) { newFocus ->
viewModel.updateFocus(item.localId, newFocus)
}
},
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue
)
@ -1139,7 +1146,8 @@ class ComposeActivity :
val mediaSize: Long,
val uploadPercent: Int = 0,
val id: String? = null,
val description: String? = null
val description: String? = null,
val focus: Attachment.Focus? = null
) {
enum class Type {
IMAGE, VIDEO, AUDIO;

View File

@ -103,7 +103,7 @@ class ComposeViewModel @Inject constructor(
private var setupComplete = false
suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result<QueuedMedia> = withContext(Dispatchers.IO) {
try {
val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first())
val mediaItems = media.value
@ -113,7 +113,7 @@ class ComposeViewModel @Inject constructor(
) {
Result.failure(VideoOrImageException())
} else {
val queuedMedia = addMediaToQueue(type, uri, size, description)
val queuedMedia = addMediaToQueue(type, uri, size, description, focus)
Result.success(queuedMedia)
}
} catch (e: Exception) {
@ -126,6 +126,7 @@ class ComposeViewModel @Inject constructor(
uri: Uri,
mediaSize: Long,
description: String? = null,
focus: Attachment.Focus? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia {
var stashMediaItem: QueuedMedia? = null
@ -136,7 +137,8 @@ class ComposeViewModel @Inject constructor(
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
description = description,
focus = focus
)
stashMediaItem = mediaItem
@ -181,7 +183,7 @@ class ComposeViewModel @Inject constructor(
return mediaItem
}
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaValue ->
val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
@ -190,7 +192,8 @@ class ComposeViewModel @Inject constructor(
mediaSize = 0,
uploadPercent = -1,
id = id,
description = description
description = description,
focus = focus
)
mediaValue + mediaItem
}
@ -245,9 +248,11 @@ class ComposeViewModel @Inject constructor(
suspend fun saveDraft(content: String, contentWarning: String) {
val mediaUris: MutableList<String> = mutableListOf()
val mediaDescriptions: MutableList<String?> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
media.value.forEach { item ->
mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description)
mediaFocus.add(item.focus)
}
draftHelper.saveDraft(
@ -260,6 +265,7 @@ class ComposeViewModel @Inject constructor(
visibility = statusVisibility.value,
mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
poll = poll.value,
failedToSend = false,
scheduledAt = scheduledAt.value,
@ -286,11 +292,13 @@ class ComposeViewModel @Inject constructor(
val mediaIds: MutableList<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaFocus: MutableList<Attachment.Focus?> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = mutableListOf()
media.value.forEach { item ->
mediaIds.add(item.id!!)
mediaUris.add(item.uri)
mediaDescriptions.add(item.description ?: "")
mediaFocus.add(item.focus)
mediaProcessed.add(false)
}
val tootToSend = StatusToSend(
@ -301,6 +309,7 @@ class ComposeViewModel @Inject constructor(
mediaIds = mediaIds,
mediaUris = mediaUris.map { it.toString() },
mediaDescriptions = mediaDescriptions,
mediaFocus = mediaFocus,
scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId,
poll = poll.value,
@ -319,11 +328,12 @@ class ComposeViewModel @Inject constructor(
}
}
suspend fun updateDescription(localId: Int, description: String): Boolean {
// Updates a QueuedMedia item arbitrarily, then sends description and focus to server
private suspend fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia): Boolean {
val newMediaList = media.updateAndGet { mediaValue ->
mediaValue.map { mediaItem ->
if (mediaItem.localId == localId) {
mediaItem.copy(description = description)
mutator(mediaItem)
} else {
mediaItem
}
@ -332,7 +342,9 @@ class ComposeViewModel @Inject constructor(
val updatedItem = newMediaList.find { it.localId == localId }
if (updatedItem?.id != null) {
return api.updateMedia(updatedItem.id, description)
val focus = updatedItem.focus
val focusString = if (focus != null) "${focus.x},${focus.y}" else null
return api.updateMedia(updatedItem.id, updatedItem.description, focusString)
.fold({
true
}, { throwable ->
@ -343,6 +355,18 @@ class ComposeViewModel @Inject constructor(
return true
}
suspend fun updateDescription(localId: Int, description: String): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(description = description)
})
}
suspend fun updateFocus(localId: Int, focus: Attachment.Focus): Boolean {
return updateMediaItem(localId, { mediaItem ->
mediaItem.copy(focus = focus)
})
}
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
@ -413,7 +437,7 @@ class ComposeViewModel @Inject constructor(
// when coming from DraftActivity
viewModelScope.launch {
draftAttachments.forEach { attachment ->
pickMedia(attachment.uri, attachment.description)
pickMedia(attachment.uri, attachment.description, attachment.focus)
}
}
} else composeOptions?.mediaAttachments?.forEach { a ->
@ -423,7 +447,7 @@ class ComposeViewModel @Inject constructor(
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
}
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus)
}
draftId = composeOptions?.draftId ?: 0

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter(
context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onAddFocus: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
@ -44,15 +45,19 @@ class MediaPreviewAdapter(
val item = differ.currentList[position]
val popup = PopupMenu(view.context, view)
val addCaptionId = 1
val editImageId = 2
val removeId = 3
val addFocusId = 2
val editImageId = 3
val removeId = 4
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE) {
popup.menu.add(0, addFocusId, 0, R.string.action_set_focus)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
}
popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
addCaptionId -> onAddCaption(item)
addFocusId -> onAddFocus(item)
editImageId -> onEditImage(item)
removeId -> onRemove(item)
}
@ -78,11 +83,24 @@ class MediaPreviewAdapter(
// TODO: Fancy waveform display?
holder.progressImageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
val imageView = holder.progressImageView
val focus = item.focus
if (focus != null)
imageView.setFocalPoint(focus)
else
imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added.
var glide = Glide.with(holder.itemView.context)
.load(item.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.progressImageView)
.centerInside()
if (focus != null)
glide = glide.addListener(imageView)
glide.into(imageView)
}
}

View File

@ -225,7 +225,13 @@ class MediaUploader @Inject constructor(
null
}
mediaUploadApi.uploadMedia(body, description).fold({ result ->
val focus = if (media.focus != null) {
MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}")
} else {
null
}
mediaUploadApi.uploadMedia(body, description, focus).fold({ result ->
send(UploadEvent.FinishedEvent(result.id))
}, { throwable ->
val errorMessage = throwable.getServerErrorMessage()

View File

@ -0,0 +1,105 @@
/* Copyright 2019 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose.dialog
import android.app.Activity
import android.content.DialogInterface
import android.graphics.drawable.Drawable
import android.net.Uri
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.DialogFocusBinding
import com.keylesspalace.tusky.entity.Attachment.Focus
import kotlinx.coroutines.launch
fun <T> T.makeFocusDialog(
existingFocus: Focus?,
previewUri: Uri,
onUpdateFocus: suspend (Focus) -> Boolean
) where T : Activity, T : LifecycleOwner {
val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center
val dialogBinding = DialogFocusBinding.inflate(layoutInflater)
dialogBinding.focusIndicator.setFocus(focus)
Glide.with(this)
.load(previewUri)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target<Drawable?>?, p3: Boolean): Boolean {
return false
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
val width = resource!!.intrinsicWidth
val height = resource.intrinsicHeight
dialogBinding.focusIndicator.setImageSize(width, height)
// We want the dialog to be a little taller than the image, so you can slide your thumb past the image border,
// but if it's *too* much taller that looks weird. See if a threshold has been crossed:
if (width > height) {
val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight()
if (dialogBinding.imageView.height > maxHeight) {
val verticalShrinkLayout = FrameLayout.LayoutParams(width, maxHeight)
dialogBinding.imageView.layoutParams = verticalShrinkLayout
dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout
}
}
return false // Pass through
}
})
.into(dialogBinding.imageView)
val okListener = { dialog: DialogInterface, _: Int ->
lifecycleScope.launch {
if (!onUpdateFocus(dialogBinding.focusIndicator.getFocus())) {
showFailedFocusMessage()
}
}
dialog.dismiss()
}
val dialog = AlertDialog.Builder(this)
.setView(dialogBinding.root)
.setPositiveButton(android.R.string.ok, okListener)
.setNegativeButton(android.R.string.cancel, null)
.create()
val window = dialog.window
window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
)
dialog.show()
}
private fun Activity.showFailedFocusMessage() {
Toast.makeText(this, R.string.error_failed_set_focus, Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,130 @@
package com.keylesspalace.tusky.components.compose.view
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Point
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.keylesspalace.tusky.entity.Attachment
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
class FocusIndicatorView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var focus: Attachment.Focus? = null
private var imageSize: Point? = null
private var circleRadius: Float? = null
fun setImageSize(width: Int, height: Int) {
this.imageSize = Point(width, height)
if (focus != null)
invalidate()
}
fun setFocus(focus: Attachment.Focus) {
this.focus = focus
if (imageSize != null)
invalidate()
}
// Assumes setFocus called first
fun getFocus(): Attachment.Focus {
return focus!!
}
// This needs to be consistent every time it is consulted over the lifetime of the object,
// so base it on the view width/height whenever the first access occurs.
private fun getCircleRadius(): Float {
val circleRadius = this.circleRadius
if (circleRadius != null)
return circleRadius
val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f
this.circleRadius = newCircleRadius
return newCircleRadius
}
// Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y)
private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame
val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1
return min(1.0f, max(-1.0f, result)) // Clamp
}
private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float {
val offset = (outerLimit - innerLimit) / 2
return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1
}
@SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget.
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL)
return false
val imageSize = this.imageSize
if (imageSize == null)
return false
// Convert touch xy to point inside image
focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height))
invalidate()
return true
}
private val transparentDarkGray = 0x40000000
private val strokeWidth = 4.0f * this.resources.displayMetrics.density
private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val curtainPath = Path()
init {
curtainPaint.color = transparentDarkGray
curtainPaint.style = Paint.Style.FILL
strokePaint.style = Paint.Style.STROKE
strokePaint.strokeWidth = strokeWidth
strokePaint.color = Color.WHITE
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val imageSize = this.imageSize
val focus = this.focus
if (imageSize != null && focus != null) {
val x = axisFromFocus(focus.x, imageSize.x, this.width)
val y = axisFromFocus(-focus.y, imageSize.y, this.height)
val circleRadius = getCircleRadius()
curtainPath.reset() // Draw a flood fill with a hole cut out of it
curtainPath.fillType = Path.FillType.WINDING
curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW)
curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW)
canvas.drawPath(curtainPath, curtainPaint)
canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle
canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot
}
}
// Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked
fun maxAttractiveHeight(): Int {
val height = this.imageSize!!.y
val circleRadius = getCircleRadius()
// Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth
return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt()
}
}

View File

@ -30,9 +30,10 @@ import androidx.appcompat.widget.AppCompatImageView;
import android.util.AttributeSet;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.view.MediaPreviewImageView;
import at.connyduck.sparkbutton.helpers.Utils;
public final class ProgressImageView extends AppCompatImageView {
public final class ProgressImageView extends MediaPreviewImageView {
private int progress = -1;
private final RectF progressRect = new RectF();

View File

@ -25,6 +25,7 @@ import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
@ -59,6 +60,7 @@ class DraftHelper @Inject constructor(
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
mediaFocus: List<Attachment.Focus?>,
poll: NewPoll?,
failedToSend: Boolean,
scheduledAt: String?,
@ -103,6 +105,7 @@ class DraftHelper @Inject constructor(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
focus = mediaFocus[i],
type = types[i]
)
)

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.drafts
import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@ -26,6 +25,7 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.view.MediaPreviewImageView
class DraftMediaAdapter(
private val attachmentClick: () -> Unit
@ -42,24 +42,34 @@ class DraftMediaAdapter(
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
return DraftMediaViewHolder(AppCompatImageView(parent.context))
return DraftMediaViewHolder(MediaPreviewImageView(parent.context))
}
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
getItem(position)?.let { attachment ->
if (attachment.type == DraftAttachment.Type.AUDIO) {
holder.imageView.clearFocus()
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
if (attachment.focus != null)
holder.imageView.setFocalPoint(attachment.focus)
else
holder.imageView.clearFocus()
var glide = Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
.centerInside()
if (attachment.focus != null)
glide = glide.addListener(holder.imageView)
glide.into(holder.imageView)
}
}
}
inner class DraftMediaViewHolder(val imageView: ImageView) :
inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) :
RecyclerView.ViewHolder(imageView) {
init {
val thumbnailViewSize =

View File

@ -22,6 +22,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.google.gson.annotations.SerializedName
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import kotlinx.parcelize.Parcelize
@ -52,6 +53,7 @@ data class DraftEntity(
data class DraftAttachment(
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
@SerializedName(value = "focus") val focus: Attachment.Focus?,
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type
) : Parcelable {
val uri: Uri

View File

@ -147,7 +147,8 @@ interface MastodonApi {
@PUT("api/v1/media/{mediaId}")
suspend fun updateMedia(
@Path("mediaId") mediaId: String,
@Field("description") description: String
@Field("description") description: String?,
@Field("focus") focus: String?
): NetworkResult<Attachment>
@GET("api/v1/media/{mediaId}")

View File

@ -15,6 +15,7 @@ interface MediaUploadApi {
@POST("api/v2/media")
suspend fun uploadMedia(
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
@Part description: MultipartBody.Part? = null,
@Part focus: MultipartBody.Part? = null
): NetworkResult<MediaUploadResult>
}

View File

@ -89,6 +89,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
mediaIds = emptyList(),
mediaUris = emptyList(),
mediaDescriptions = emptyList(),
mediaFocus = emptyList(),
scheduledAt = null,
inReplyToId = citedStatusId,
poll = null,

View File

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.NewStatus
import com.keylesspalace.tusky.entity.Status
@ -258,6 +259,7 @@ class SendStatusService : Service(), Injectable {
visibility = Status.Visibility.byString(status.visibility),
mediaUris = status.mediaUris,
mediaDescriptions = status.mediaDescriptions,
mediaFocus = status.mediaFocus,
poll = status.poll,
failedToSend = true,
scheduledAt = status.scheduledAt,
@ -359,6 +361,7 @@ data class StatusToSend(
val mediaIds: List<String>,
val mediaUris: List<String>,
val mediaDescriptions: List<String>,
val mediaFocus: List<Attachment.Focus?>,
val scheduledAt: String?,
val inReplyToId: String?,
val poll: NewPoll?,

View File

@ -37,7 +37,7 @@ import com.keylesspalace.tusky.util.FocalPointUtil
* However if there is no focal point set (e.g. it is null), then this view should simply
* act exactly the same as an ordinary android ImageView.
*/
class MediaPreviewImageView
open class MediaPreviewImageView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- todo add padding -->
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.keylesspalace.tusky.components.compose.view.FocusIndicatorView
android:id="@+id/focusIndicator"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@ -404,10 +404,12 @@
<string name="compose_active_account_description">Posting as %1$s</string>
<string name="error_failed_set_caption">Failed to set caption</string>
<string name="error_failed_set_focus">Failed to set focus point</string>
<plurals name="hint_describe_for_visually_impaired">
<item quantity="other">Describe for visually impaired\n(%d character limit)</item>
</plurals>
<string name="action_set_caption">Set caption</string>
<string name="action_set_focus">Set focus point</string>
<string name="action_edit_image">Edit image</string>
<string name="action_remove">Remove</string>
<string name="lock_account_label">Lock account</string>