Drafts and scheduled posts (#217)

closes #100
closes #59 

* enable saving as draft (scheduled)
* add scheduled posts list
* fix NoSuchMethodError
* editable drafts/scheduled posts
* ui for drafts
* use instants between 9999-01-01 and 9999-12-31
* use save and draft strings
* map scheduled status params to status
* implement scheduling posts
* improve save/discard draft dialog
* persist scheduled date in state
* add unsent posts button to toolbar
* clean up imports
This commit is contained in:
sk22
2022-12-29 16:53:18 +01:00
committed by GitHub
parent 40d44269fc
commit 9547be89e1
25 changed files with 789 additions and 138 deletions

View File

@@ -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<Status>{
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<ScheduledStatus>{
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<String> mediaIds;

View File

@@ -7,4 +7,10 @@ public class DeleteStatus extends MastodonAPIRequest<Status>{
public DeleteStatus(String id){
super(HttpMethod.DELETE, "/statuses/"+id, Status.class);
}
public static class Scheduled extends MastodonAPIRequest<Object> {
public Scheduled(String id) {
super(HttpMethod.DELETE, "/scheduled_statuses/"+id, Object.class);
}
}
}

View File

@@ -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<ScheduledStatus>{
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+"");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<Status> 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<String> 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();
}

View File

@@ -79,7 +79,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
case FAVORITE -> 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<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n);
if(titleItem!=null){

View File

@@ -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;
}

View File

@@ -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<ScheduledStatus> {
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<StatusDisplayItem> 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<ScheduledStatus> 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<displayItems.size();i++){
if(status.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(status.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
// copied from StatusListFragment.java
protected ScheduledStatus getStatusByID(String id){
for(ScheduledStatus s:data){
if(s.id.equals(id)){
return s;
}
}
for(ScheduledStatus s:preloadedData){
if(s.id.equals(id)){
return s;
}
}
return null;
}
}

View File

@@ -57,6 +57,11 @@ public class Poll extends BaseModel{
public String title;
public Integer votesCount;
public Option() {}
public Option(String title) {
this.title = title;
}
@Override
public String toString(){
return "Option{"+

View File

@@ -0,0 +1,79 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.model.Poll.Option;
import org.parceler.Parcel;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
@Parcel
public class ScheduledStatus extends BaseModel implements DisplayItemsParent{
@RequiredField
public String id;
@RequiredField
public Instant scheduledAt;
@RequiredField
public Params params;
@RequiredField
public List<Attachment> 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<String> mediaIds;
}
@Parcel
public static class ScheduledPoll {
@RequiredField
public String expiresIn;
@RequiredField
public List<String> 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;
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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<Status> resultCallback){
showConfirmationAlert(activity,
pinned ? R.string.sk_confirm_pin_post_title : R.string.sk_confirm_unpin_post_title,

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
<path android:pathData="M10 2c4.418 0 8 3.582 8 8s-3.582 8-8 8-8-3.582-8-8 3.582-8 8-8zm0 1c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.134-7-7-7zM9.5 5c0.245 0 0.45 0.177 0.492 0.41L10 5.5V10h2.5c0.276 0 0.5 0.224 0.5 0.5 0 0.245-0.177 0.45-0.41 0.492L12.5 11h-3c-0.245 0-0.45-0.177-0.492-0.41L9 10.5v-5C9 5.224 9.224 5 9.5 5z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M15.25 13.5h-4c-0.414 0-0.75-0.336-0.75-0.75v-6C10.5 6.336 10.836 6 11.25 6S12 6.336 12 6.75V12h3.25c0.414 0 0.75 0.336 0.75 0.75s-0.336 0.75-0.75 0.75zM12 2C6.478 2 2 6.478 2 12s4.478 10 10 10 10-4.478 10-10S17.522 2 12 2z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 2c5.523 0 10 4.478 10 10s-4.477 10-10 10S2 17.522 2 12 6.477 2 12 2zm0 1.667c-4.595 0-8.333 3.738-8.333 8.333 0 4.595 3.738 8.333 8.333 8.333 4.595 0 8.333-3.738 8.333-8.333 0-4.595-3.738-8.333-8.333-8.333zM11.25 6c0.38 0 0.694 0.282 0.743 0.648L12 6.75V12h3.25c0.414 0 0.75 0.336 0.75 0.75 0 0.38-0.282 0.694-0.648 0.743L15.25 13.5h-4c-0.38 0-0.694-0.282-0.743-0.648L10.5 12.75v-6C10.5 6.336 10.836 6 11.25 6z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--~ Copyright (c) 2022. ~ Microsoft Corporation. All rights reserved.-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_fluent_clock_24_filled" android:state_activated="true"/>
<item android:drawable="@drawable/ic_fluent_clock_24_filled" android:state_checked="true"/>
<item android:drawable="@drawable/ic_fluent_clock_24_filled" android:state_selected="true"/>
<item android:drawable="@drawable/ic_fluent_clock_24_regular"/>
</selector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
<path android:pathData="M13.245 2.817L3.64 12.423 3.522 12.55c-0.185 0.22-0.322 0.48-0.398 0.76l-1.106 4.055-0.015 0.08c-0.038 0.34 0.282 0.628 0.63 0.534l4.054-1.106 0.165-0.053c0.271-0.1 0.518-0.257 0.723-0.462l9.606-9.606 0.13-0.14c0.955-1.093 0.911-2.754-0.13-3.796-1.087-1.087-2.849-1.087-3.936 0zM4.346 13.13l8.039-8.038 2.521 2.52-8.038 8.04-0.098 0.085-0.107 0.072c-0.075 0.044-0.155 0.077-0.239 0.1l-3.212 0.876 0.877-3.211 0.042-0.123c0.05-0.12 0.123-0.229 0.215-0.321zm12.128-9.606l0.11 0.12c0.584 0.7 0.547 1.744-0.11 2.402l-0.861 0.86-2.521-2.521 0.86-0.86 0.12-0.11c0.7-0.585 1.744-0.548 2.402 0.11zM11.648 3H2.5C2.224 3 2 3.224 2 3.5S2.224 4 2.5 4h8.148l1-1zm-3 3H2.5C2.224 6 2 6.223 2 6.5 2 6.776 2.224 7 2.5 7h5.148l1-1zm-4 4l1-1H2.5C2.224 9 2 9.223 2 9.5 2 9.776 2.224 10 2.5 10h2.148z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M20.878 2.826l0.153 0.144 0.145 0.153c1.25 1.405 1.203 3.56-0.145 4.908L9.063 19.999c-0.277 0.277-0.621 0.477-1 0.58l-5.115 1.395c-0.56 0.153-1.073-0.361-0.92-0.921l1.394-5.116c0.103-0.377 0.303-0.722 0.58-0.999L15.97 2.97c1.348-1.348 3.503-1.396 4.908-0.144zM15.001 6.06l-9.938 9.938c-0.092 0.092-0.16 0.207-0.193 0.333l-1.05 3.85 3.85-1.05c0.125-0.035 0.24-0.101 0.332-0.194L17.94 9l-2.939-2.94zM6.526 11l-1.5 1.5H2.75C2.337 12.5 2 12.165 2 11.75 2 11.336 2.337 11 2.75 11h3.775zm4-4l-1.5 1.5H2.75C2.337 8.5 2 8.165 2 7.75 2 7.336 2.337 7 2.75 7h7.775zm6.505-2.97L16.061 5 19 7.94l0.97-0.97c0.812-0.812 0.812-2.128 0-2.94-0.811-0.811-2.127-0.811-2.939 0zM14.526 3l-1.5 1.5H2.75C2.337 4.5 2 4.165 2 3.75 2 3.336 2.337 3 2.75 3h11.775z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -229,94 +229,153 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="48dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?colorBackgroundLightest"
android:elevation="2dp"
android:outlineProvider="bounds"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:layoutDirection="locale">
<ImageButton
android:id="@+id/btn_media"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/add_media"
android:tooltipText="@string/add_media"
android:src="@drawable/ic_fluent_image_24_regular"/>
<ImageButton
android:id="@+id/btn_poll"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/add_poll"
android:tooltipText="@string/add_poll"
android:src="@drawable/ic_fluent_poll_24_selector"/>
<ImageButton
android:id="@+id/btn_emoji"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/emoji"
android:tooltipText="@string/emoji"
android:src="@drawable/ic_fluent_emoji_24_selector"/>
<ImageButton
android:id="@+id/btn_spoiler"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/content_warning"
android:tooltipText="@string/content_warning"
android:src="@drawable/ic_fluent_chat_warning_24_selector"/>
<ImageButton
android:id="@+id/btn_visibility"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/post_visibility"
android:tooltipText="@string/post_visibility"
android:src="@drawable/ic_fluent_earth_24_regular"/>
<Space
android:layout_width="0px"
android:layout_height="1px"
android:layout_weight="1"/>
<TextView
android:id="@+id/char_counter"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/schedule_draft_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_body_large"
android:textColor="?android:textColorSecondary"
tools:text="500"/>
android:gravity="center_vertical"
android:minHeight="48dp"
android:paddingTop="4dp">
<TextView
android:id="@+id/schedule_draft_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/sk_compose_draft" />
<Button
android:id="@+id/scheduled_time_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:gravity="center"
android:padding="8dp"
android:textSize="14sp"
android:minHeight="48dp"
android:textColor="?android:textColorSecondary"
android:background="@drawable/bg_text_button"
android:fontFamily="sans-serif"
android:drawableStart="@drawable/ic_fluent_clock_20_regular"
android:drawablePadding="8dp"
android:drawableTint="?android:textColorSecondary"
tools:text="Dec 12, 2021, 12:42 PM"/>
<ImageButton
android:id="@+id/schedule_draft_dismiss"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="-4dp"
android:src="@drawable/ic_fluent_dismiss_20_filled"
android:background="?android:selectableItemBackgroundBorderless"
android:padding="4dp"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="48dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/btn_media"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/add_media"
android:tooltipText="@string/add_media"
android:src="@drawable/ic_fluent_image_24_regular"/>
<ImageButton
android:id="@+id/btn_poll"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/add_poll"
android:tooltipText="@string/add_poll"
android:src="@drawable/ic_fluent_poll_24_selector"/>
<ImageButton
android:id="@+id/btn_emoji"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/emoji"
android:tooltipText="@string/emoji"
android:src="@drawable/ic_fluent_emoji_24_selector"/>
<ImageButton
android:id="@+id/btn_spoiler"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/content_warning"
android:tooltipText="@string/content_warning"
android:src="@drawable/ic_fluent_chat_warning_24_selector"/>
<ImageButton
android:id="@+id/btn_visibility"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/post_visibility"
android:tooltipText="@string/post_visibility"
android:src="@drawable/ic_fluent_earth_24_regular"/>
<ImageButton
android:id="@+id/btn_schedule"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:contentDescription="@string/sk_draft_or_schedule"
android:tooltipText="@string/sk_draft_or_schedule"
android:src="@drawable/ic_fluent_clock_24_selector"/>
<Space
android:layout_width="0px"
android:layout_height="1px"
android:layout_weight="1"/>
<TextView
android:id="@+id/char_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_body_large"
android:textColor="?android:textColorSecondary"
tools:text="500"/>
</LinearLayout>
</LinearLayout>
</org.joinmastodon.android.ui.views.SizeListenerLinearLayout>

View File

@@ -3,5 +3,6 @@
<item android:id="@+id/followed_hashtags" android:title="@string/sk_hashtags_you_follow" android:icon="@drawable/ic_fluent_number_symbol_24_regular" android:showAsAction="always"/>
<item android:id="@+id/bookmarks" android:title="@string/bookmarks" android:icon="@drawable/ic_fluent_bookmark_multiple_24_regular" android:showAsAction="always"/>
<item android:id="@+id/favorites" android:title="@string/your_favorites" android:icon="@drawable/ic_fluent_star_24_regular"/>
<item android:id="@+id/scheduled" android:title="@string/sk_unsent_posts" android:icon="@drawable/ic_fluent_drafts_24_regular"/>
<item android:id="@+id/share" android:title="@string/share_user" android:icon="@drawable/ic_fluent_share_24_regular"/>
</menu>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/schedule" android:title="@string/sk_schedule" android:icon="@drawable/ic_fluent_clock_24_regular" />
<item android:id="@+id/draft" android:title="@string/sk_draft" android:icon="@drawable/ic_fluent_drafts_24_regular" />
</menu>

View File

@@ -103,4 +103,20 @@
<string name="sk_reply_as">Reply with other account</string>
<string name="sk_settings_uniform_icon_for_notifications">Uniform icon for all notifications</string>
<string name="sk_forward_report_to">Forward to %s</string>
<string name="sk_unsent_posts">Unsent posts</string>
<string name="sk_draft">Draft</string>
<string name="sk_schedule">Schedule</string>
<string name="sk_confirm_delete_draft_title">Delete draft</string>
<string name="sk_confirm_delete_draft">Are you sure you want to delete this drafted post?</string>
<string name="sk_confirm_delete_scheduled_post_title">Delete scheduled post</string>
<string name="sk_confirm_delete_scheduled_post">Are you sure you want to delete this scheduled post?</string>
<string name="sk_draft_or_schedule">Draft or schedule</string>
<string name="sk_compose_draft">Post will be saved as a draft.</string>
<string name="sk_compose_scheduled">Scheduled for</string>
<string name="sk_draft_saved">Draft saved</string>
<string name="sk_post_scheduled">Post scheduled</string>
<string name="sk_scheduled_too_soon_title">Scheduled time is too soon</string>
<string name="sk_scheduled_too_soon">Post must be scheduled at least 10 minutes in the future.</string>
<string name="sk_save_draft">Save draft?</string>
<string name="sk_save_changes">Save changes?</string>
</resources>

View File

@@ -29,6 +29,8 @@
<item name="android:navigationBarColor">?android:statusBarColor</item>
<item name="android:actionBarTheme">@style/Theme.Mastodon.Toolbar</item>
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
<item name="android:datePickerDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
<item name="android:timePickerDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
<item name="colorPollMostVoted">?colorPrimary500</item>
<item name="colorPollVoted">?colorGray300</item>
<item name="colorAccentLight">?colorPrimary600</item>
@@ -123,6 +125,8 @@
<item name="android:navigationBarColor">?android:statusBarColor</item>
<item name="android:actionBarTheme">@style/Theme.Mastodon.Toolbar.Dark</item>
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert.Dark</item>
<item name="android:datePickerDialogTheme">@style/Theme.Mastodon.Dialog.Alert.Dark</item>
<item name="android:timePickerDialogTheme">@style/Theme.Mastodon.Dialog.Alert.Dark</item>
<item name="colorPollMostVoted">?colorPrimary700</item>
<item name="colorPollVoted">?colorGray600</item>
<item name="colorAccentLight">?colorPrimary600</item>
@@ -318,6 +322,8 @@
<item name="android:dialogPreferredPadding">24dp</item>
<item name="android:windowBackground">@drawable/bg_alert</item>
<item name="android:buttonBarButtonStyle">@style/Widget.Mastodon.ButtonBarButton</item>
<item name="android:datePickerStyle">@style/Widget.Mastodon.DatePicker.Dark</item>
<item name="android:timePickerStyle">@style/Widget.Mastodon.TimePicker.Dark</item>
<!-- colors -->
<item name="android:colorAccent">?colorPrimary600</item>
@@ -327,6 +333,15 @@
<item name="android:textColorSecondary">?colorGray400</item>
</style>
<style name="Widget.Mastodon.DatePicker.Dark" parent="@android:style/Widget.Material.DatePicker">
<item name="android:headerBackground">?colorGray700</item>
</style>
<style name="Widget.Mastodon.TimePicker.Dark" parent="@android:style/Widget.Material.TimePicker">
<item name="android:headerBackground">?colorGray700</item>
<item name="android:numbersBackgroundColor">?colorGray700</item>
</style>
<style name="Widget.Mastodon.ButtonBarButton" parent="android:Widget.Material.Button.Borderless">
<item name="android:textAllCaps">false</item>
<item name="android:layout_marginEnd">8dp</item>