diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java index bdb9dd14d..6a9c0cea1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; @@ -9,12 +10,29 @@ import java.util.ArrayList; import java.util.List; public class CreateStatus extends MastodonAPIRequest{ + public static final Instant DRAFTS_AFTER_INSTANT = Instant.ofEpochMilli(253370764799999L) /* end of 9998 */; + private static final float draftFactor = 31536000000f /* one year */ / 253370764799999f /* end of 9998 */; + + public static Instant getDraftInstant() { + // returns an instant between 9999-01-01 00:00:00 and 9999-12-31 23:59:59 + // yes, this is a weird implementation for something that hardly matters + return DRAFTS_AFTER_INSTANT.plusMillis(1 + (long) (System.currentTimeMillis() * draftFactor)); + } + public CreateStatus(CreateStatus.Request req, String uuid){ super(HttpMethod.POST, "/statuses", Status.class); setRequestBody(req); addHeader("Idempotency-Key", uuid); } + public static class Scheduled extends MastodonAPIRequest{ + public Scheduled(CreateStatus.Request req, String uuid){ + super(HttpMethod.POST, "/statuses", ScheduledStatus.class); + setRequestBody(req); + addHeader("Idempotency-Key", uuid); + } + } + public static class Request{ public String status; public List mediaIds; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java index edc8a70bc..3730a64f6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/DeleteStatus.java @@ -7,4 +7,10 @@ public class DeleteStatus extends MastodonAPIRequest{ public DeleteStatus(String id){ super(HttpMethod.DELETE, "/statuses/"+id, Status.class); } + + public static class Scheduled extends MastodonAPIRequest { + public Scheduled(String id) { + super(HttpMethod.DELETE, "/scheduled_statuses/"+id, Object.class); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java new file mode 100644 index 000000000..fec163f2f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetScheduledStatuses.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.statuses; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.ScheduledStatus; + +public class GetScheduledStatuses extends HeaderPaginationRequest{ + public GetScheduledStatuses(String maxID, int limit){ + super(HttpMethod.GET, "/scheduled_statuses", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java new file mode 100644 index 000000000..b11fb2375 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusCreatedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.ScheduledStatus; + +public class ScheduledStatusCreatedEvent { + public final ScheduledStatus scheduledStatus; + public final String accountID; + + public ScheduledStatusCreatedEvent(ScheduledStatus scheduledStatus, String accountID){ + this.scheduledStatus = scheduledStatus; + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java new file mode 100644 index 000000000..96aaeac26 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/ScheduledStatusDeletedEvent.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.events; + +import org.joinmastodon.android.model.ScheduledStatus; + +public class ScheduledStatusDeletedEvent{ + public final String id; + public final String accountID; + + public ScheduledStatusDeletedEvent(String id, String accountID){ + this.id=id; + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 54fea39e1..035f405f8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -1,12 +1,16 @@ package org.joinmastodon.android.fragments; import static org.joinmastodon.android.GlobalUserPreferences.recentLanguages; +import static org.joinmastodon.android.api.requests.statuses.CreateStatus.DRAFTS_AFTER_INSTANT; +import static org.joinmastodon.android.api.requests.statuses.CreateStatus.getDraftInstant; import static org.joinmastodon.android.utils.MastodonLanguage.allLanguages; import static org.joinmastodon.android.utils.MastodonLanguage.defaultRecentLanguages; import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; +import android.app.DatePickerDialog; +import android.app.TimePickerDialog; import android.content.ClipData; import android.content.Context; import android.content.Intent; @@ -32,8 +36,8 @@ import android.text.Layout; import android.text.Spanned; import android.text.TextUtils; import android.text.TextWatcher; +import android.text.format.DateFormat; import android.util.Log; -import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; @@ -67,13 +71,15 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.ProgressListener; -import org.joinmastodon.android.api.requests.accounts.GetPreferences; import org.joinmastodon.android.api.requests.statuses.CreateStatus; +import org.joinmastodon.android.api.requests.statuses.DeleteStatus; import org.joinmastodon.android.api.requests.statuses.EditStatus; import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID; import org.joinmastodon.android.api.requests.statuses.UploadAttachment; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; +import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; @@ -85,6 +91,7 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Preferences; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.ComposeAutocompleteViewController; @@ -109,6 +116,12 @@ import org.parceler.Parcels; import java.io.InterruptedIOException; import java.net.SocketException; import java.net.UnknownHostException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -154,9 +167,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private String accountID; private int charCount, charLimit, trimmedCharCount; - private Button publishButton, languageButton; - private PopupMenu languagePopup, visibilityPopup; - private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn; + private Button publishButton, languageButton, scheduleTimeBtn; + private PopupMenu languagePopup, visibilityPopup, scheduleDraftPopup; + private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn, scheduleBtn, scheduleDraftDismiss; private ImageView sensitiveIcon; private ComposeMediaLayout attachmentsView; private TextView replyText; @@ -165,6 +178,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private View addPollOptionBtn; private View sensitiveItem; private View pollAllowMultipleItem; + private View scheduleDraftView; + private TextView scheduleDraftText; private CheckBox pollAllowMultipleCheckbox; private TextView pollDurationView; @@ -182,6 +197,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private EditText spoilerEdit; private boolean hasSpoiler; private boolean sensitive; + private Instant scheduledAt = null; private ProgressBar sendProgress; private ImageView sendError; private View sendingOverlay; @@ -194,6 +210,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private boolean attachmentsErrorShowing; private Status editingStatus; + private ScheduledStatus scheduledStatus; private boolean redraftStatus; private boolean pollChanged; private boolean creatingView; @@ -219,9 +236,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain); instance=AccountSessionManager.getInstance().getInstanceInfo(instanceDomain); languageResolver=new MastodonLanguage.LanguageResolver(instance); + redraftStatus=getArguments().getBoolean("redraftStatus", false); if(getArguments().containsKey("editStatus")){ editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus")); - redraftStatus=getArguments().getBoolean("redraftStatus"); } if(instance==null){ Nav.finish(this); @@ -231,6 +248,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr AccountSessionManager.getInstance().updateInstanceInfo(instanceDomain); } + // sorry about all this ugly code, but i can't find any consistency in ComposeFragment.java + Bundle bundle = savedInstanceState != null ? savedInstanceState : getArguments(); + if (bundle.containsKey("scheduledStatus")) scheduledStatus=Parcels.unwrap(bundle.getParcelable("scheduledStatus")); + if (bundle.containsKey("scheduledAt")) scheduledAt=(Instant) bundle.getSerializable("scheduledAt"); + if(instance.maxTootChars>0) charLimit=instance.maxTootChars; else if(instance.configuration!=null && instance.configuration.statuses!=null && instance.configuration.statuses.maxCharacters>0) @@ -293,6 +315,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr emojiBtn=view.findViewById(R.id.btn_emoji); spoilerBtn=view.findViewById(R.id.btn_spoiler); visibilityBtn=view.findViewById(R.id.btn_visibility); + scheduleBtn=view.findViewById(R.id.btn_schedule); + scheduleDraftView=view.findViewById(R.id.schedule_draft_view); + scheduleDraftText=view.findViewById(R.id.schedule_draft_text); + scheduleDraftDismiss=view.findViewById(R.id.schedule_draft_dismiss); + scheduleTimeBtn=view.findViewById(R.id.scheduled_time_btn); sensitiveIcon=view.findViewById(R.id.sensitive_icon); sensitiveItem=view.findViewById(R.id.sensitive_item); replyText=view.findViewById(R.id.reply_text); @@ -304,6 +331,23 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr buildVisibilityPopup(visibilityBtn); visibilityBtn.setOnClickListener(v->visibilityPopup.show()); visibilityBtn.setOnTouchListener(visibilityPopup.getDragToOpenListener()); + + scheduleDraftPopup=new PopupMenu(getContext(), scheduleBtn); + scheduleDraftPopup.inflate(R.menu.schedule_draft); + scheduleDraftPopup.setOnMenuItemClickListener(item->{ + if (item.getItemId() == R.id.draft) updateScheduledAt(getDraftInstant()); + else pickScheduledDateTime(); + return true; + }); + UiUtils.enablePopupMenuIcons(getContext(), scheduleDraftPopup); + scheduleBtn.setOnClickListener(v->{ + if (scheduledAt != null) updateScheduledAt(null); + else scheduleDraftPopup.show(); + }); + scheduleBtn.setOnTouchListener(scheduleDraftPopup.getDragToOpenListener()); + scheduleDraftDismiss.setOnClickListener(v->updateScheduledAt(null)); + scheduleTimeBtn.setOnClickListener(v->pickScheduledDateTime()); + sensitiveItem.setOnClickListener(v->toggleSensitive()); emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){ @Override @@ -353,8 +397,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr DraftPollOption opt=createDraftPollOption(); opt.edit.setText(eopt.title); } - pollDuration=(int)editingStatus.poll.expiresAt.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getEpochSecond(); - pollDurationStr=UiUtils.formatTimeLeft(getActivity(), editingStatus.poll.expiresAt); + pollDuration=scheduledStatus == null + ? (int)editingStatus.poll.expiresAt.minus(System.currentTimeMillis(), ChronoUnit.MILLIS).getEpochSecond() + : Integer.parseInt(scheduledStatus.params.poll.expiresIn); + pollDurationStr=UiUtils.formatTimeLeft(getActivity(), scheduledStatus == null + ? editingStatus.poll.expiresAt + : Instant.now().plus(pollDuration, ChronoUnit.SECONDS)); updatePollOptionHints(); pollDurationView.setText(getString(R.string.compose_poll_duration, pollDurationStr)); }else{ @@ -440,6 +488,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr outState.putParcelableArrayList("attachments", serializedAttachments); } outState.putSerializable("visibility", statusVisibility); + if (scheduledAt != null) outState.putSerializable("scheduledAt", scheduledAt); + if (scheduledStatus != null) outState.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); } @Override @@ -622,6 +672,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } updateSensitive(); + updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); if(editingStatus!=null){ updateCharCounter(); @@ -632,12 +683,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ publishButton=new Button(getActivity()); - int publishText = editingStatus==null || redraftStatus ? R.string.publish : R.string.save; - if (publishText == R.string.publish && !GlobalUserPreferences.publishButtonText.isEmpty()) { - publishButton.setText(GlobalUserPreferences.publishButtonText); - } else { - publishButton.setText(publishText); - } + resetPublishButtonText(); publishButton.setSingleLine(); publishButton.setEllipsize(TextUtils.TruncateAt.END); publishButton.setOnClickListener(this::onPublishClick); @@ -756,6 +802,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updatePublishButtonState(); } + private void resetPublishButtonText() { + int publishText = editingStatus==null || redraftStatus ? R.string.publish : R.string.save; + if (publishText == R.string.publish && !GlobalUserPreferences.publishButtonText.isEmpty()) { + publishButton.setText(GlobalUserPreferences.publishButtonText); + } else { + publishButton.setText(publishText); + } + } + private void updatePublishButtonState(){ uuid=null; int nonEmptyPollOptionsCount=0; @@ -771,6 +826,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr nonDoneAttachmentCount++; } publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); + sendError.setVisibility(View.GONE); } private void onCustomEmojiClick(Emoji emoji){ @@ -782,6 +838,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override protected void updateToolbar(){ super.updateToolbar(); + if (replyTo != null || hasDraft()) return; + Button draftsBtn=new Button(getActivity()); + draftsBtn.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary)); + draftsBtn.setBackground(getActivity().getDrawable(R.drawable.bg_text_button)); + draftsBtn.setPadding(V.dp(8), 0, V.dp(8), 0); + draftsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(getActivity().getDrawable(R.drawable.ic_fluent_drafts_20_regular), null, null, null); + draftsBtn.setCompoundDrawableTintList(draftsBtn.getTextColors()); + draftsBtn.setContentDescription(getString(R.string.sk_unsent_posts)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) draftsBtn.setTooltipText(getString(R.string.sk_unsent_posts)); + draftsBtn.setOnClickListener(v->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + imm.hideSoftInputFromWindow(draftsBtn.getWindowToken(), 0); + Nav.go(getActivity(), ScheduledStatusListFragment.class, args); + if (!hasDraft()) Nav.finish(this); + }); + getToolbar().addView(draftsBtn); getToolbar().setNavigationIcon(R.drawable.ic_fluent_dismiss_24_regular); } @@ -789,6 +863,43 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr publish(); } + private void publishErrorCallback(ErrorResponse error) { + wm.removeView(sendingOverlay); + sendingOverlay=null; + sendProgress.setVisibility(View.GONE); + sendError.setVisibility(View.VISIBLE); + publishButton.setEnabled(true); + if (error != null) error.showToast(getActivity()); + } + + private void createScheduledStatusFinish(ScheduledStatus result) { + wm.removeView(sendingOverlay); + sendingOverlay=null; + Toast.makeText(getContext(), scheduledAt.isAfter(DRAFTS_AFTER_INSTANT) ? + R.string.sk_draft_saved : R.string.sk_post_scheduled, Toast.LENGTH_SHORT).show(); + Nav.finish(ComposeFragment.this); + E.post(new ScheduledStatusCreatedEvent(result, accountID)); + } + + private void maybeDeleteScheduledPost(Runnable callback) { + if (scheduledStatus != null) { + new DeleteStatus.Scheduled(scheduledStatus.id).setCallback(new Callback<>() { + @Override + public void onSuccess(Object o) { + E.post(new ScheduledStatusDeletedEvent(scheduledStatus.id, accountID)); + callback.run(); + } + + @Override + public void onError(ErrorResponse error) { + publishErrorCallback(error); + } + }).exec(accountID); + } else { + callback.run(); + } + } + private void publish(){ String text=mainEditText.getText().toString(); CreateStatus.Request req=new CreateStatus.Request(); @@ -796,6 +907,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr req.visibility=statusVisibility; req.sensitive=sensitive; req.language=language; + req.scheduledAt = scheduledAt; if(!attachments.isEmpty()){ req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); } @@ -832,35 +944,32 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Callback resCallback=new Callback<>(){ @Override public void onSuccess(Status result){ - wm.removeView(sendingOverlay); - sendingOverlay=null; - if(editingStatus==null){ - E.post(new StatusCreatedEvent(result, accountID)); - if(replyTo!=null){ - replyTo.repliesCount++; - E.post(new StatusCountersUpdatedEvent(replyTo)); + maybeDeleteScheduledPost(() -> { + wm.removeView(sendingOverlay); + sendingOverlay=null; + if(editingStatus==null){ + E.post(new StatusCreatedEvent(result, accountID)); + if(replyTo!=null){ + replyTo.repliesCount++; + E.post(new StatusCountersUpdatedEvent(replyTo)); + } + }else{ + E.post(new StatusUpdatedEvent(result)); } - }else{ - E.post(new StatusUpdatedEvent(result)); - } - Nav.finish(ComposeFragment.this); - if (getArguments().getBoolean("navigateToStatus", false)) { - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putParcelable("status", Parcels.wrap(result)); - if(replyTo!=null) args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo)); - Nav.go(getActivity(), ThreadFragment.class, args); - } + Nav.finish(ComposeFragment.this); + if (getArguments().getBoolean("navigateToStatus", false)) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("status", Parcels.wrap(result)); + if(replyTo!=null) args.putParcelable("inReplyToAccount", Parcels.wrap(replyTo)); + Nav.go(getActivity(), ThreadFragment.class, args); + } + }); } @Override public void onError(ErrorResponse error){ - wm.removeView(sendingOverlay); - sendingOverlay=null; - sendProgress.setVisibility(View.GONE); - sendError.setVisibility(View.VISIBLE); - publishButton.setEnabled(true); - error.showToast(getActivity()); + publishErrorCallback(error); } }; @@ -868,10 +977,37 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr new EditStatus(req, editingStatus.id) .setCallback(resCallback) .exec(accountID); - }else{ + }else if(req.scheduledAt == null){ new CreateStatus(req, uuid) .setCallback(resCallback) .exec(accountID); + }else if(req.scheduledAt.isAfter(Instant.now().plus(10, ChronoUnit.MINUTES))){ + // checking for 10 instead of 5 minutes (as per mastodon) because i really don't want + // bugs to occur because the client's clock is wrong by a minute or two - the api + // returns a status instead of a scheduled status if scheduled time is less than 5 + // minutes into the future and this is 1. unexpected for the user and 2. hard to handle + new CreateStatus.Scheduled(req, uuid) + .setCallback(new Callback<>() { + @Override + public void onSuccess(ScheduledStatus result) { + maybeDeleteScheduledPost(() -> { + createScheduledStatusFinish(result); + }); + } + + @Override + public void onError(ErrorResponse error) { + publishErrorCallback(error); + } + }).exec(accountID); + }else{ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_scheduled_too_soon_title) + .setMessage(R.string.sk_scheduled_too_soon) + .setPositiveButton(R.string.ok, (a, b)->{}) + .show(); + publishErrorCallback(null); + publishButton.setEnabled(false); } if (replyTo == null) { @@ -891,6 +1027,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr List existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList()); if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()))) return true; + if(!statusVisibility.equals(editingStatus.visibility)) return true; return pollChanged; } boolean pollFieldsHaveContent=false; @@ -940,9 +1077,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void confirmDiscardDraftAndFinish(){ new M3AlertDialogBuilder(getActivity()) - .setTitle(editingStatus==null ? R.string.discard_draft : R.string.discard_changes) - .setPositiveButton(R.string.discard, (dialog, which)->Nav.finish(this)) - .setNegativeButton(R.string.cancel, null) + .setTitle(editingStatus != null ? R.string.sk_save_changes : R.string.sk_save_draft) + .setPositiveButton(R.string.save, (d, w) -> { + updateScheduledAt(getDraftInstant()); + publish(); + }) + .setNegativeButton(R.string.discard, (d, w) -> Nav.finish(this)) .show(); } @@ -1121,7 +1261,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onProgress(long transferred, long total){ if(updateUploadEtaRunnable==null){ - UiUtils.runOnUiThread(updateUploadEtaRunnable=ComposeFragment.this::updateUploadETAs, 100); + // getting a NoSuchMethodError: No static method -$$Nest$mupdateUploadETAs(ComposeFragment;)V in class ComposeFragment + // when using method reference out of nowhere after changing code elsewhere. no idea. programming is awful, actually + // noinspection Convert2MethodRef + UiUtils.runOnUiThread(updateUploadEtaRunnable=()->ComposeFragment.this.updateUploadETAs(), 50); } int progress=Math.round(transferred/(float)total*attachment.progressBar.getMax()); if(Build.VERSION.SDK_INT>=24) @@ -1284,7 +1427,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr att.uploadStateText.setText(getString(R.string.file_upload_time_remaining, time)); } } - UiUtils.runOnUiThread(updateUploadEtaRunnable, 100); + UiUtils.runOnUiThread(updateUploadEtaRunnable, 50); } private void onEditMediaDescriptionClick(View v){ @@ -1416,6 +1559,42 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if (attachments.isEmpty()) sensitive = false; } + private void pickScheduledDateTime() { + LocalDateTime soon = LocalDateTime.now() + .plus(15, ChronoUnit.MINUTES) // so 14:59 doesn't get rounded up to… + .plus(1, ChronoUnit.HOURS) // …15:00, but rather 16:00 + .withMinute(0); + new DatePickerDialog(getActivity(), (datePicker, year, arrayMonth, dayOfMonth) -> { + new TimePickerDialog(getActivity(), (timePicker, hour, minute) -> { + updateScheduledAt(LocalDateTime.of(year, arrayMonth + 1, dayOfMonth, hour, minute) + .toInstant(OffsetDateTime.now().getOffset())); + }, soon.getHour(), soon.getMinute(), DateFormat.is24HourFormat(getActivity())).show(); + }, soon.getYear(), soon.getMonthValue() - 1, soon.getDayOfMonth()).show(); + } + + private void updateScheduledAt(Instant scheduledAt) { + this.scheduledAt = scheduledAt; + scheduleDraftView.setVisibility(scheduledAt == null ? View.GONE : View.VISIBLE); + scheduleBtn.setSelected(scheduledAt != null); + updatePublishButtonState(); + if (scheduledAt != null) { + DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault()); + if (scheduledAt.isAfter(DRAFTS_AFTER_INSTANT)) { + scheduleTimeBtn.setVisibility(View.GONE); + scheduleDraftText.setText(R.string.sk_compose_draft); + publishButton.setText(scheduledStatus != null ? R.string.save : R.string.sk_draft); + } else { + String at = scheduledAt.atZone(ZoneId.systemDefault()).format(formatter); + scheduleTimeBtn.setVisibility(View.VISIBLE); + scheduleTimeBtn.setText(at); + scheduleDraftText.setText(R.string.sk_compose_scheduled); + publishButton.setText(scheduledStatus != null ? R.string.save : R.string.sk_schedule); + } + } else { + resetPublishButtonText(); + } + } + private int getMediaAttachmentsCount(){ return attachments.size(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index bf4936c34..97ec6f770 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -79,7 +79,7 @@ public class NotificationsListFragment extends BaseStatusListFragment getString(R.string.user_favorited); case POLL -> getString(R.string.poll_ended); }; - HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, null, extraText, n) : null; + HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, null, extraText, n, null) : null; if(n.status!=null){ ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n); if(titleItem!=null){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index 770efee67..8ec7bc555 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -642,6 +642,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), FollowedHashtagsFragment.class, args); + }else if(id==R.id.scheduled){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ScheduledStatusListFragment.class, args); } return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java new file mode 100644 index 000000000..5fed551fd --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -0,0 +1,143 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses; +import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ScheduledStatusCreatedEvent; +import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; +import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.events.StatusDeletedEvent; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.ScheduledStatus; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; +import org.parceler.Parcels; + +import java.util.Collections; +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.SimpleCallback; + +public class ScheduledStatusListFragment extends BaseStatusListFragment { + private String nextMaxID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); + } + + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setTitle(R.string.sk_unsent_posts); + loadData(); + } + + @Override + protected List buildDisplayItems(ScheduledStatus s) { + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null); + } + + @Override + protected void addAccountToKnown(ScheduledStatus s) {} + + @Override + public void onItemClick(String id) { + final Bundle args=new Bundle(); + args.putString("account", accountID); + ScheduledStatus scheduledStatus = getStatusByID(id); + Status status = scheduledStatus.toStatus(); + args.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); + args.putParcelable("editStatus", Parcels.wrap(status)); + args.putString("sourceText", status.text); + args.putString("sourceSpoiler", status.spoilerText); + args.putBoolean("redraftStatus", true); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetScheduledStatuses(offset==0 ? null : nextMaxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + onDataLoaded(result, nextMaxID!=null); + } + }) + .exec(accountID); + } + + // copied from StatusListFragment.java + @Subscribe + public void onScheduledStatusDeleted(ScheduledStatusDeletedEvent ev){ + if(!ev.accountID.equals(accountID)) return; + ScheduledStatus status=getStatusByID(ev.id); + if(status==null) return; + removeStatus(status); + } + + // copied from StatusListFragment.java + @Subscribe + public void onScheduledStatusCreated(ScheduledStatusCreatedEvent ev){ + if(!ev.accountID.equals(accountID)) return; + prependItems(Collections.singletonList(ev.scheduledStatus), true); + scrollToTop(); + } + + // copied from StatusListFragment.java + protected void removeStatus(ScheduledStatus status){ + data.remove(status); + preloadedData.remove(status); + int index=-1; + for(int i=0;i mediaAttachments; + + @Override + public String getID() { + return id; + } + + @Parcel + public static class Params { + @RequiredField + public String text; + public String spoilerText; + @RequiredField + public StatusPrivacy visibility; + public long inReplyToId; + public ScheduledPoll poll; + public boolean sensitive; + public boolean withRateLimit; + public String language; + public String idempotency; + public String applicationId; + public List mediaIds; + } + + @Parcel + public static class ScheduledPoll { + @RequiredField + public String expiresIn; + @RequiredField + public List options; + public boolean multiple; + public boolean hideTotals; + + public Poll toPoll() { + Poll p = new Poll(); + p.voted = true; + p.emojis = List.of(); + p.ownVotes = List.of(); + p.multiple = multiple; + p.options = options.stream().map(Option::new).collect(Collectors.toList()); + return p; + } + } + + public Status toStatus() { + Status s = new Status(); + s.id = id; + s.mediaAttachments = mediaAttachments; + s.createdAt = scheduledAt; + s.content = s.text = params.text; + s.spoilerText = params.spoilerText; + s.visibility = params.visibility; + s.language = params.language; + s.mentions = List.of(); + s.tags = List.of(); + s.emojis = List.of(); + if (params.poll != null) s.poll = params.poll.toPoll(); + return s; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index d54ea8bfd..f3e6bf02c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -23,6 +23,7 @@ import android.widget.Toast; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.GetStatusSourceText; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -36,6 +37,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; @@ -43,9 +45,12 @@ import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; +import java.util.Locale; import me.grishka.appkit.Nav; import me.grishka.appkit.api.APIRequest; @@ -68,9 +73,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ boolean needBottomPadding; private String extraText; private Notification notification; + private ScheduledStatus scheduledStatus; - public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status, String extraText, Notification notification){ + public HeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment parentFragment, String accountID, Status status, String extraText, Notification notification, ScheduledStatus scheduledStatus){ super(parentID, parentFragment); + user=scheduledStatus != null ? AccountSessionManager.getInstance().getAccount(accountID).self : user; this.user=user; this.createdAt=createdAt; avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? user.avatar : user.avatarStatic, V.dp(50), V.dp(50)); @@ -78,6 +85,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ parsedName=new SpannableStringBuilder(user.displayName); this.status=status; this.notification=notification; + this.scheduledStatus=scheduledStatus; HtmlParser.parseCustomEmoji(parsedName, user.emojis); emojiHelper.setText(parsedName); if(status!=null){ @@ -167,6 +175,12 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ } if(TextUtils.isEmpty(item.status.content) && TextUtils.isEmpty(item.status.spoilerText)){ Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); + }else if(item.scheduledStatus!=null){ + args.putString("sourceText", item.status.text); + args.putString("sourceSpoiler", item.status.spoilerText); + args.putBoolean("redraftStatus", true); + args.putParcelable("scheduledStatus", Parcels.wrap(item.scheduledStatus)); + Nav.go(item.parentFragment.getActivity(), ComposeFragment.class, args); }else{ new GetStatusSourceText(item.status.id) .setCallback(new Callback<>(){ @@ -192,7 +206,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ .exec(item.parentFragment.getAccountID()); } }else if(id==R.id.delete){ - UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{}); + if (item.scheduledStatus != null) { + UiUtils.confirmDeleteScheduledPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.scheduledStatus, ()->{}); + } else { + UiUtils.confirmDeletePost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, s->{}); + } }else if(id==R.id.pin || id==R.id.unpin) { UiUtils.confirmPinPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, !item.status.pinned, s->{}); }else if(id==R.id.mute){ @@ -249,7 +267,14 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public void onBind(HeaderStatusDisplayItem item){ name.setText(item.parsedName); username.setText('@'+item.user.acct); - if(item.status==null || item.status.editedAt==null) + if (item.scheduledStatus!=null) + if (item.scheduledStatus.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT)) { + timestamp.setText(R.string.sk_draft); + } else { + DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault()); + timestamp.setText(item.scheduledStatus.scheduledAt.atZone(ZoneId.systemDefault()).format(formatter)); + } + else if(item.status==null || item.status.editedAt==null) timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt)); else timestamp.setText(item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt))); @@ -342,14 +367,15 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ Account account=item.user; boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account); - menu.findItem(R.id.open_with_account).setVisible(hasMultipleAccounts); + boolean isPostScheduled=item.scheduledStatus!=null; + menu.findItem(R.id.open_with_account).setVisible(!isPostScheduled && hasMultipleAccounts); menu.findItem(R.id.edit).setVisible(item.status!=null && isOwnPost); menu.findItem(R.id.delete).setVisible(item.status!=null && isOwnPost); - menu.findItem(R.id.delete_and_redraft).setVisible(item.status!=null && isOwnPost); - menu.findItem(R.id.pin).setVisible(item.status!=null && isOwnPost && !item.status.pinned); - menu.findItem(R.id.unpin).setVisible(item.status!=null && isOwnPost && item.status.pinned); - menu.findItem(R.id.open_in_browser).setVisible(item.status!=null); - menu.findItem(R.id.copy_link).setVisible(item.status!=null); + menu.findItem(R.id.delete_and_redraft).setVisible(!isPostScheduled && item.status!=null && isOwnPost); + menu.findItem(R.id.pin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && !item.status.pinned); + menu.findItem(R.id.unpin).setVisible(!isPostScheduled && item.status!=null && isOwnPost && item.status.pinned); + menu.findItem(R.id.open_in_browser).setVisible(!isPostScheduled && item.status!=null); + menu.findItem(R.id.copy_link).setVisible(!isPostScheduled && item.status!=null); MenuItem blockDomain=menu.findItem(R.id.block_domain); MenuItem mute=menu.findItem(R.id.mute); MenuItem block=menu.findItem(R.id.block); @@ -365,7 +391,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ bookmark.setVisible(false); } */ - if(isOwnPost){ + if(isPostScheduled || isOwnPost){ mute.setVisible(false); block.setVisible(false); report.setVisible(false); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index f42f494c1..36e907616 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -17,6 +17,7 @@ import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.DisplayItemsParent; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Poll; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.text.HtmlParser; @@ -81,6 +82,8 @@ public abstract class StatusDisplayItem{ Status statusForContent=status.getContentStatus(); Bundle args=new Bundle(); args.putString("account", accountID); + ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus ? (ScheduledStatus) parentObject : null; + if(status.reblog!=null){ boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account); items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.user_boosted, status.account.displayName), status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20_filled, isOwnPost ? status.visibility : null, i->{ @@ -95,7 +98,7 @@ public abstract class StatusDisplayItem{ })); } HeaderStatusDisplayItem header; - items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, notification)); + items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, notification, scheduledStatus)); if(!TextUtils.isEmpty(statusForContent.content)) items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent)); else diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index 48bdde0d6..da207418a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -54,11 +54,13 @@ import org.joinmastodon.android.api.requests.accounts.AuthorizeFollowRequest; import org.joinmastodon.android.api.requests.accounts.RejectFollowRequest; import org.joinmastodon.android.api.requests.notifications.DismissNotification; import org.joinmastodon.android.api.requests.search.GetSearchResults; +import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.DeleteStatus; import org.joinmastodon.android.api.requests.statuses.GetStatusByID; import org.joinmastodon.android.api.requests.statuses.SetStatusPinned; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.ScheduledStatusDeletedEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.FollowRequestHandledEvent; import org.joinmastodon.android.events.NotificationDeletedEvent; @@ -76,11 +78,11 @@ import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.SearchResults; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.text.CustomEmojiSpan; -import org.joinmastodon.android.ui.text.SpacerSpan; import org.parceler.Parcels; import java.io.File; @@ -466,6 +468,31 @@ public class UiUtils{ ); } + public static void confirmDeleteScheduledPost(Activity activity, String accountID, ScheduledStatus status, Runnable resultCallback){ + boolean isDraft = status.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT); + showConfirmationAlert(activity, + isDraft ? R.string.sk_confirm_delete_draft_title : R.string.sk_confirm_delete_scheduled_post_title, + isDraft ? R.string.sk_confirm_delete_draft : R.string.sk_confirm_delete_scheduled_post, + R.string.delete, + R.drawable.ic_fluent_delete_28_regular, + () -> new DeleteStatus.Scheduled(status.id) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Object nothing){ + resultCallback.run(); + E.post(new ScheduledStatusDeletedEvent(status.id, accountID)); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(activity); + } + }) + .wrapProgress(activity, R.string.deleting, false) + .exec(accountID) + ); + } + public static void confirmPinPost(Activity activity, String accountID, Status status, boolean pinned, Consumer resultCallback){ showConfirmationAlert(activity, pinned ? R.string.sk_confirm_pin_post_title : R.string.sk_confirm_unpin_post_title, diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_20_regular.xml new file mode 100644 index 000000000..8858954c5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled.xml new file mode 100644 index 000000000..beec42fea --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_regular.xml new file mode 100644 index 000000000..23e62f033 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml new file mode 100644 index 000000000..53c57862a --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_clock_24_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_drafts_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_drafts_20_regular.xml new file mode 100644 index 000000000..f6b22cb5f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_drafts_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_drafts_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_regular.xml new file mode 100644 index 000000000..9343ff2fe --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_drafts_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/layout/fragment_compose.xml b/mastodon/src/main/res/layout/fragment_compose.xml index 0b68ff319..40c28a96f 100644 --- a/mastodon/src/main/res/layout/fragment_compose.xml +++ b/mastodon/src/main/res/layout/fragment_compose.xml @@ -229,94 +229,153 @@ - - - - - - - - - - - - - - + android:gravity="center_vertical" + android:minHeight="48dp" + android:paddingTop="4dp"> + +