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 704756cf2..22f4ca314 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) @@ -295,6 +317,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); @@ -315,6 +342,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 @@ -364,8 +408,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{ @@ -404,9 +452,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(editingStatus!=null && editingStatus.visibility!=null) { statusVisibility=editingStatus.visibility; + } else { + loadDefaultStatusVisibility(savedInstanceState); } - loadDefaultStatusVisibility(savedInstanceState); updateVisibilityIcon(); visibilityPopup.getMenu().findItem(switch(statusVisibility){ case PUBLIC -> R.id.vis_public; @@ -450,6 +499,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 @@ -544,7 +595,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_people_community_20_regular; case PRIVATE -> R.drawable.ic_fluent_people_checkmark_20_regular; - case DIRECT -> R.drawable.ic_fluent_mention_24_regular; + case DIRECT -> R.drawable.ic_fluent_mention_20_regular; }); visibilityIcon.setBounds(0, 0, V.dp(20), V.dp(20)); Drawable replyArrow = getActivity().getDrawable(R.drawable.ic_fluent_arrow_reply_20_filled); @@ -632,6 +683,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } updateSensitive(); + updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); if(editingStatus!=null){ updateCharCounter(); @@ -643,7 +695,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(!GlobalUserPreferences.relocatePublishButton){ publishButton=new Button(getActivity()); - publishButton.setText(editingStatus==null || redraftStatus ? R.string.publish : R.string.save); + resetPublishButtonText(); + publishButton.setSingleLine(); + publishButton.setEllipsize(TextUtils.TruncateAt.END); publishButton.setOnClickListener(this::onPublishClick); } LinearLayout wrap=new LinearLayout(getActivity()); @@ -692,11 +746,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("ClickableViewAccessibility") private Button buildLanguageSelector() { - TypedValue typedValue = new TypedValue(); - getActivity().getTheme().resolveAttribute(android.R.attr.textColorSecondary, typedValue, true); - languageButton=new Button(getActivity()); - languageButton.setTextColor(typedValue.data); + languageButton.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary)); languageButton.setBackground(getActivity().getDrawable(R.drawable.bg_text_button)); languageButton.setPadding(V.dp(8), 0, V.dp(8), 0); languageButton.setCompoundDrawablesRelativeWithIntrinsicBounds(getActivity().getDrawable(R.drawable.ic_fluent_local_language_16_regular), null, null, null); @@ -767,6 +818,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; @@ -782,6 +842,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){ @@ -793,6 +854,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); } @@ -800,6 +879,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(); @@ -807,6 +923,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()); } @@ -843,35 +960,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); } }; @@ -879,10 +993,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) { @@ -902,6 +1043,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; @@ -951,9 +1093,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(); } @@ -1132,7 +1277,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) @@ -1295,7 +1443,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){ @@ -1427,6 +1575,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 c63848b45..97ec6f770 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -2,6 +2,8 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; import android.view.View; import com.squareup.otto.Subscribe; @@ -10,7 +12,6 @@ import org.joinmastodon.android.E; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.NotificationDeletedEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.model.Notification; @@ -78,9 +79,9 @@ 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) : 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); + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n); if(titleItem!=null){ for(StatusDisplayItem item:items){ if(item instanceof ImageStatusDisplayItem imgItem){ @@ -210,7 +211,7 @@ public class NotificationsListFragment 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 66c421a1f..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 @@ -11,6 +11,7 @@ import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.Menu; import android.view.MenuItem; +import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; @@ -22,28 +23,34 @@ 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; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.fragments.NotificationsListFragment; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Attachment; -import org.joinmastodon.android.model.Preferences; +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.model.StatusPrivacy; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; 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.Objects; +import java.util.Locale; import me.grishka.appkit.Nav; import me.grishka.appkit.api.APIRequest; @@ -65,15 +72,20 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ private boolean hasVisibilityToggle; 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){ + 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)); this.accountID=accountID; 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){ @@ -110,7 +122,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ public static class Holder extends StatusDisplayItem.Holder implements ImageLoaderViewHolder{ private final TextView name, username, timestamp, extraText; - private final ImageView avatar, more, visibility; + private final ImageView avatar, more, visibility, deleteNotification; private final PopupMenu optionsMenu; private Relationship relationship; private APIRequest currentRelationshipRequest; @@ -130,19 +142,25 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ avatar=findViewById(R.id.avatar); more=findViewById(R.id.more); visibility=findViewById(R.id.visibility); + deleteNotification=findViewById(R.id.delete_notification); extraText=findViewById(R.id.extra_text); avatar.setOnClickListener(this::onAvaClick); avatar.setOutlineProvider(roundCornersOutline); avatar.setClipToOutline(true); more.setOnClickListener(this::onMoreClick); - more.setOnLongClickListener((v) -> { openWithAccount(); return true; }); visibility.setOnClickListener(v->item.parentFragment.onVisibilityIconClick(this)); + deleteNotification.setOnClickListener(v->UiUtils.confirmDeleteNotification(activity, item.parentFragment.getAccountID(), item.notification, ()->{ + if (item.parentFragment instanceof NotificationsListFragment fragment) { + fragment.removeNotification(item.notification); + } + })); optionsMenu=new PopupMenu(activity, more); optionsMenu.inflate(R.menu.post); optionsMenu.setOnMenuItemClickListener(menuItem->{ Account account=item.user; int id=menuItem.getItemId(); + if(id==R.id.edit || id==R.id.delete_and_redraft) { final Bundle args=new Bundle(); args.putString("account", item.parentFragment.getAccountID()); @@ -157,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<>(){ @@ -182,12 +206,13 @@ 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.open_with_account) { - openWithAccount(); + UiUtils.confirmPinPost(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), item.status, !item.status.pinned, s->{}); }else if(id==R.id.mute){ UiUtils.confirmToggleMuteUser(item.parentFragment.getActivity(), item.parentFragment.getAccountID(), account, relationship!=null && relationship.muting, r->{}); }else if(id==R.id.block){ @@ -198,8 +223,10 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ args.putParcelable("status", Parcels.wrap(item.status)); args.putParcelable("reportAccount", Parcels.wrap(item.status.account)); Nav.go(item.parentFragment.getActivity(), ReportReasonChoiceFragment.class, args); - }else if(id==R.id.open_in_browser){ + }else if(id==R.id.open_in_browser) { UiUtils.launchWebBrowser(activity, item.status.url); + }else if(id==R.id.copy_link){ + UiUtils.copyText(parent, item.status.url); }else if(id==R.id.follow){ if(relationship==null) return true; @@ -213,7 +240,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ progress.dismiss(); }, rel->{ relationship=rel; - Toast.makeText(activity, activity.getString(rel.following ? R.string.followed_user : R.string.unfollowed_user, account.getDisplayUsername()), Toast.LENGTH_SHORT).show(); + Toast.makeText(activity, activity.getString(rel.following ? R.string.followed_user : R.string.unfollowed_user, account.getShortUsername()), Toast.LENGTH_SHORT).show(); }); }else if(id==R.id.block_domain){ UiUtils.confirmToggleBlockDomain(activity, item.parentFragment.getAccountID(), account.getDomain(), relationship!=null && relationship.domainBlocking, ()->{}); @@ -225,22 +252,34 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ UiUtils.enablePopupMenuIcons(activity, optionsMenu); } - private void openWithAccount() { - UiUtils.pickAccount(item.parentFragment.getActivity(), (session, dialog) -> { - UiUtils.openURL(item.parentFragment.getActivity(), session.getID(), item.status.url); - return true; - }, R.string.sk_open_in_account); + private void populateAccountsMenu(Menu menu) { + List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); + sessions.stream().filter(s -> !s.getID().equals(item.accountID)).forEach(s -> { + String username = "@"+s.self.username+"@"+s.domain; + menu.add(username).setOnMenuItemClickListener(c->{ + UiUtils.openURL(item.parentFragment.getActivity(), s.getID(), item.status.url, false); + return true; + }); + }); } @Override 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))); visibility.setVisibility(item.hasVisibilityToggle && !item.inset ? View.VISIBLE : View.GONE); + deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification!=null && !item.inset ? View.VISIBLE : View.GONE); if(item.hasVisibilityToggle){ visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility); visibility.setContentDescription(item.parentFragment.getString(item.status.spoilerRevealed ? R.string.hide_content : R.string.reveal_content)); @@ -313,18 +352,32 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ } private void updateOptionsMenu(){ - Account account=item.user; + boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; Menu menu=optionsMenu.getMenu(); + + MenuItem openWithAccounts = menu.findItem(R.id.open_with_account); + SubMenu accountsMenu = openWithAccounts != null ? openWithAccounts.getSubMenu() : null; + if (hasMultipleAccounts && accountsMenu != null) { + openWithAccounts.setVisible(true); + accountsMenu.clear(); + populateAccountsMenu(accountsMenu); + } else if (openWithAccounts != null) { + openWithAccounts.setVisible(false); + } + + Account account=item.user; boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account); + 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.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 hideBoosts=menu.findItem(R.id.hide_boosts); MenuItem block=menu.findItem(R.id.block); MenuItem report=menu.findItem(R.id.report); MenuItem follow=menu.findItem(R.id.follow); @@ -338,9 +391,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ bookmark.setVisible(false); } */ - if(isOwnPost){ + if(isPostScheduled || isOwnPost){ mute.setVisible(false); - hideBoosts.setVisible(false); block.setVisible(false); report.setVisible(false); follow.setVisible(false); @@ -350,11 +402,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ block.setVisible(true); report.setVisible(true); follow.setVisible(relationship==null || relationship.following || (!relationship.blocking && !relationship.blockedBy && !relationship.domainBlocking && !relationship.muting)); - mute.setTitle(item.parentFragment.getString(relationship!=null && relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername())); - mute.setIcon(relationship!=null && relationship.muting ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_mute_24_regular); + mute.setTitle(item.parentFragment.getString(relationship!=null && relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); + mute.setIcon(relationship!=null && relationship.muting ? R.drawable.ic_fluent_speaker_0_24_regular : R.drawable.ic_fluent_speaker_off_24_regular); UiUtils.insetPopupMenuIcon(item.parentFragment.getContext(), mute); - block.setTitle(item.parentFragment.getString(relationship!=null && relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername())); - report.setTitle(item.parentFragment.getString(R.string.report_user, account.getDisplayUsername())); + block.setTitle(item.parentFragment.getString(relationship!=null && relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getShortUsername())); + report.setTitle(item.parentFragment.getString(R.string.report_user, account.getShortUsername())); // disabled in megalodon. domain blocks from a post clutters the context menu and looks out of place // if(!account.isLocal()){ // blockDomain.setVisible(true); @@ -363,7 +415,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ blockDomain.setVisible(false); // } boolean following = relationship!=null && relationship.following; - follow.setTitle(item.parentFragment.getString(following ? R.string.unfollow_user : R.string.follow_user, account.getDisplayUsername())); + follow.setTitle(item.parentFragment.getString(following ? R.string.unfollow_user : R.string.follow_user, account.getShortUsername())); follow.setIcon(following ? R.drawable.ic_fluent_person_delete_24_regular : R.drawable.ic_fluent_person_add_24_regular); UiUtils.insetPopupMenuIcon(item.parentFragment.getContext(), follow); } 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 4a9d3c8a7..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 @@ -15,7 +15,9 @@ import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; 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; @@ -74,12 +76,14 @@ public abstract class StatusDisplayItem{ }; } - public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter){ + public static ArrayList buildItems(BaseStatusListFragment fragment, Status status, String accountID, DisplayItemsParent parentObject, Map knownAccounts, boolean inset, boolean addFooter, Notification notification){ String parentID=parentObject.getID(); ArrayList items=new ArrayList<>(); 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->{ @@ -94,7 +98,7 @@ public abstract class StatusDisplayItem{ })); } HeaderStatusDisplayItem header; - items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null)); + 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 376b47987..4b46a9fa5 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 @@ -58,11 +58,13 @@ import org.joinmastodon.android.api.requests.accounts.RejectFollowRequest; //import org.joinmastodon.android.api.requests.notification.DismissNotification; 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; @@ -80,11 +82,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; @@ -470,6 +472,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 438779cda..db35a12f8 100644 --- a/mastodon/src/main/res/layout/fragment_compose.xml +++ b/mastodon/src/main/res/layout/fragment_compose.xml @@ -196,6 +196,7 @@ android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginBottom="16dp" android:layout_marginTop="8dp" android:gravity="center_vertical" android:layoutDirection="locale" @@ -228,94 +229,153 @@ - - - - - - - - - - - - - - + android:gravity="center_vertical" + android:minHeight="48dp" + android:paddingTop="4dp"> + +