diff --git a/apps/desktop/electron-builder.config.js b/apps/desktop/electron-builder.config.js index 1c8c0caa2..a13e962fc 100644 --- a/apps/desktop/electron-builder.config.js +++ b/apps/desktop/electron-builder.config.js @@ -203,11 +203,12 @@ module.exports = { toolsets: { appimage: "1.0.2" }, - snap: { - autoStart: false, - confinement: "strict", - allowNativeWayland: true, - base: "core22" + snapcraft: { + base: "core24", + core24: { + confinement: "strict", + autoStart: false + } }, extraResources: ["app-update.yml", "./assets/**"], extraMetadata: { diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 7cc709a78..f90b30f98 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -140,7 +140,7 @@ android { if (project.hasProperty("prBuildNumber")) { versionCode Integer.parseInt(prBuildNumber()) } else { - versionCode 3113 + versionCode 3114 } versionName getNpmVersion() testBuildType System.getProperty('testBuildType', 'debug') @@ -233,6 +233,9 @@ dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") + // Lets the widgets push their list data straight into the RemoteViews on every API level, + // instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively). + implementation("androidx.core:core-remoteviews:1.0.0") implementation("androidx.core:core-splashscreen:1.0.0") implementation 'androidx.multidex:multidex:2.0.1' diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 4f46df45c..a3267c669 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -86,8 +86,9 @@ android:name=".NotePreviewWidget" android:exported="false" android:label="@string/note"> - " + + + + + + + + + - - = MIN_HEIGHT_FOR_BODY_DP; + } + @Override public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); + // This used to do nothing at all, so resizing the widget left it rendered for its old size. + updateAppWidget(context, appWidgetManager, appWidgetId); } - private static Bundle getActivityOptionsBundle() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ActivityOptions activityOptions = ActivityOptions.makeBasic(); - activityOptions.setPendingIntentCreatorBackgroundActivityStartMode( - ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); - return activityOptions.toBundle(); - } else - return null; + /** + * The note shown by each widget is stored in the "appPreview" preferences under its widget id. + * When the system restores our widgets it hands out fresh ids, so unless we move the stored + * notes over to the new ids the widgets are left permanently blank with no way to recover + * other than removing and re-adding them. + * + * AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them. + */ + @Override + public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) { + super.onRestored(context, oldWidgetIds, newWidgetIds); + if (oldWidgetIds == null || newWidgetIds == null) return; + + int count = Math.min(oldWidgetIds.length, newWidgetIds.length); + SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE); + + // Read everything up front: an old id can collide with the new id of another widget. + String[] notes = new String[count]; + Set newKeys = new HashSet<>(); + for (int i = 0; i < count; i++) { + notes[i] = preferences.getString(String.valueOf(oldWidgetIds[i]), ""); + newKeys.add(String.valueOf(newWidgetIds[i])); + } + + SharedPreferences.Editor edit = preferences.edit(); + for (int i = 0; i < count; i++) { + String oldKey = String.valueOf(oldWidgetIds[i]); + if (!newKeys.contains(oldKey)) { + edit.remove(oldKey); + } + } + for (int i = 0; i < count; i++) { + if (notes[i].isEmpty()) continue; + edit.putString(String.valueOf(newWidgetIds[i]), notes[i]); + } + edit.apply(); } @Override diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/NoteWidget.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/NoteWidget.java index 6207f5ef1..6efe9ed1e 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/NoteWidget.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/NoteWidget.java @@ -1,12 +1,10 @@ package com.streetwriters.notesnook; -import android.app.ActivityOptions; import android.app.PendingIntent; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; -import android.os.Build; import android.os.Bundle; import android.widget.RemoteViews; @@ -24,20 +22,10 @@ public class NoteWidget extends AppWidgetProvider { static void setClickIntent(Context context, RemoteViews views) { Intent intent = new Intent(context, ShareActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle()); views.setOnClickPendingIntent(R.id.new_note, pendingIntent); } - private static Bundle getActivityOptionsBundle() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ActivityOptions activityOptions = ActivityOptions.makeBasic(); - activityOptions.setPendingIntentCreatorBackgroundActivityStartMode( - ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); - return activityOptions.toBundle(); - } else - return null; - } - @Override public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java index 806630b69..ac04cebcb 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/RCTNNativeModule.java @@ -12,7 +12,6 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; -import android.graphics.RectF; import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; @@ -29,7 +28,6 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; -import com.google.gson.Gson; import com.streetwriters.notesnook.datatypes.Note; import java.util.ArrayList; @@ -138,7 +136,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule { if (Objects.equals(extras.getString(IntentType), "NewReminder")) { map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder)); } else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) { - map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId)); + map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId)); } else if (Objects.equals(extras.getString(IntentType), "OpenNote")) { map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId)); } @@ -156,14 +154,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule { @ReactMethod public void getWidgetNotes(Promise promise) { - SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE); - Map map = pref.getAll(); WritableArray arr = Arguments.createArray(); - for(Map.Entry entry : map.entrySet()){ - if (entry.getKey().equals("remindersList")) continue; - String value = (String) entry.getValue(); - Gson gson = new Gson(); - Note note = gson.fromJson(value, Note.class); + for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) { arr.pushString(note.getId()); } promise.resolve(arr); @@ -171,36 +163,46 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule { @ReactMethod public void hasWidgetNote(final String noteId, Promise promise) { - SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE); - Map map = pref.getAll(); boolean found = false; - for(Map.Entry entry : map.entrySet()){ - String value = (String) entry.getValue(); - if (value.contains(noteId)) { + for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) { + if (note.getId().equals(noteId)) { found = true; + break; } } promise.resolve(found); } + @ReactMethod public void updateWidgetNote(final String noteId, final String data) { - SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE); - Map map = pref.getAll(); + SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE); SharedPreferences.Editor edit = pref.edit(); - ArrayList ids = new ArrayList<>(); - for(Map.Entry entry : map.entrySet()) { - String value = (String) entry.getValue(); - if (value.contains(noteId)) { - edit.putString(entry.getKey(), data); - ids.add(entry.getKey()); - } + List ids = new ArrayList<>(); + + // Match on the note's id, not on the raw JSON containing it somewhere: a note whose body + // happens to mention another note's id is not the same note. + for (Map.Entry entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) { + if (!noteId.equals(entry.getValue().getId())) continue; + edit.putString(String.valueOf(entry.getKey()), data); + ids.add(entry.getKey()); } edit.apply(); - for (String id: ids) { - NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id)); + + for (int id : ids) { + NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id); } } + /** + * Redraws every widget from scratch. Needed because the app can be stopped while its widgets + * stay on the home screen: clearing app data empties the store without the widgets ever being + * told, so they keep showing content that is gone until something forces a redraw. + */ + @ReactMethod + public void refreshWidgets() { + WidgetUtils.refreshAll(mContext); + } + @ReactMethod public void updateReminderWidget() { AppWidgetManager wm = AppWidgetManager.getInstance(mContext); @@ -208,8 +210,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule { for (int id: ids) { Log.d("Reminders", "Updating" + id); RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders); + // The rows are part of this update, so there is nothing left to invalidate afterwards. ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views); - wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view); } } diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderViewsService.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderViewsService.java deleted file mode 100644 index 5e9416e39..000000000 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderViewsService.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.streetwriters.notesnook; - -import android.app.ActivityOptions; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.util.Log; -import android.widget.RemoteViewsService; -import android.content.Context; -import android.widget.RemoteViews; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.streetwriters.notesnook.datatypes.Reminder; - -import java.util.ArrayList; -import java.util.List; - -public class ReminderViewsService extends RemoteViewsService { - static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId"; - @Override - public RemoteViewsFactory onGetViewFactory(Intent intent) { - return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent); - } -} - -class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory { - private Context context; - private List reminders; - - public ReminderRemoteViewsFactory(Context context, Intent intent) { - this.context = context; - } - - @Override - public void onCreate() { - // Initialize reminders list - reminders = new ArrayList(); - } - - @Override - public void onDataSetChanged() { - reminders.clear(); - SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE); - Gson gson = new Gson(); - reminders = gson.fromJson(preferences.getString("remindersList","[]"), new TypeToken>(){}.getType()); - - } - - @Override - public void onDestroy() { - reminders.clear(); - } - - @Override - public int getCount() { - return reminders.size(); - } - - @Override - public RemoteViews getViewAt(int position) { - Reminder reminder = reminders.get(position); - - boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty(); - - RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout); - - views.setTextViewText(R.id.reminder_title, reminder.getTitle()); - if (!useMiniLayout) { - views.setTextViewText(R.id.reminder_description, reminder.getDescription()); - } - views.setTextViewText(R.id.reminder_time, reminder.getFormattedTime()); - final Intent fillInIntent = new Intent(); - final Bundle extras = new Bundle(); - extras.putString(ReminderViewsService.OpenReminderId, reminder.getId()); - fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId())); - fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder"); - fillInIntent.putExtras(extras); - views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent); - return views; - } - - - @Override - public RemoteViews getLoadingView() { - return null; - } - - @Override - public int getViewTypeCount() { - return 2; - } - - @Override - public long getItemId(int position) { - - return position; - } - - @Override - public boolean hasStableIds() { - return true; - } -} \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java index 3ecd4daed..bcbfb0233 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/ReminderWidgetProvider.java @@ -1,18 +1,22 @@ package com.streetwriters.notesnook; -import android.app.ActivityOptions; import android.app.PendingIntent; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.os.Build; -import android.os.Bundle; import android.widget.RemoteViews; +import androidx.core.widget.RemoteViewsCompat; + +import com.streetwriters.notesnook.datatypes.Reminder; + +import java.util.List; + public class ReminderWidgetProvider extends AppWidgetProvider { static String NewReminder = "com.streetwriters.notesnook.NewReminder"; + static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId"; @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { @@ -23,20 +27,10 @@ public class ReminderWidgetProvider extends AppWidgetProvider { } - private static Bundle getActivityOptionsBundle() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ActivityOptions activityOptions = ActivityOptions.makeBasic(); - activityOptions.setPendingIntentCreatorBackgroundActivityStartMode( - ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); - return activityOptions.toBundle(); - } else - return null; - } - static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) { Intent listview_intent_template = new Intent(context, MainActivity.class); listview_intent_template.setAction(Intent.ACTION_VIEW); - PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle()); views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent); Intent new_reminder_intent = new Intent(context, MainActivity.class); @@ -44,13 +38,31 @@ public class ReminderWidgetProvider extends AppWidgetProvider { new_reminder_intent.setAction(Intent.ACTION_VIEW); new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder"); new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder")); - PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle()); + PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle()); views.setOnClickPendingIntent(R.id.add_button, pendingIntent2); - Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class); - list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); - views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent); + // The rows travel with the update itself, so there is no bound service to keep in sync and + // nothing to invalidate separately: every update redraws from the current data. + List reminders = WidgetUtils.getWidgetReminders(context); + RemoteViewsCompat.RemoteCollectionItems.Builder items = + new RemoteViewsCompat.RemoteCollectionItems.Builder(); + for (Reminder reminder : reminders) { + items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder)); + } + // Two, because a reminder without a description uses the compact row layout. + items.setViewTypeCount(2); + items.setHasStableIds(true); + + RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build()); views.setEmptyView(R.id.widget_list_view, R.id.empty_view); appWidgetManager.updateAppWidget(appWidgetId, views); } -} \ No newline at end of file + + /** + * Ties a row to its reminder rather than to its position, so rows keep their identity when the + * list shifts around them. + */ + private static long getItemId(Reminder reminder) { + return reminder.getId() == null ? 0 : reminder.getId().hashCode(); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetTimeChangeReceiver.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetTimeChangeReceiver.java new file mode 100644 index 000000000..957f6769a --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetTimeChangeReceiver.java @@ -0,0 +1,28 @@ +package com.streetwriters.notesnook; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Redraws the widgets when the clock or timezone changes. + * + * Reminder rows are described relative to the current time ("Upcoming"/"Last", "Today"/"Tomorrow"), + * and a reminder that has passed drops off the list entirely. All of that is decided when the + * widget is drawn, so moving the clock leaves the previous drawing in place: a reminder can still + * read "Upcoming" long after its time has gone by. + * + * The app normally redraws the list when a reminder notification is delivered, but that does not + * happen if the reminder never fires, which is exactly the case when the clock jumps past it. + * + * Only TIME_SET and TIMEZONE_CHANGED are handled here: DATE_CHANGED is not exempt from the + * Android 8 limits on manifest-registered implicit broadcasts, so a receiver for it would never + * run. Crossing midnight is instead picked up by the widget's own periodic update. + */ +public class WidgetTimeChangeReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + WidgetUtils.refreshAll(context); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java new file mode 100644 index 000000000..c23d55c44 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/WidgetUtils.java @@ -0,0 +1,299 @@ +package com.streetwriters.notesnook; + +import android.app.ActivityOptions; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.text.format.DateUtils; +import android.util.Log; +import android.widget.RemoteViews; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.streetwriters.notesnook.datatypes.Note; +import com.streetwriters.notesnook.datatypes.Reminder; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Shared helpers for the home screen widgets. + */ +public class WidgetUtils { + + static final String PREFERENCES = "appPreview"; + static final String REMINDERS_KEY = "remindersList"; + + /** + * Every row is serialized into the widget update itself, which has to fit inside a binder + * transaction, so the list cannot grow without bound. Far more than fits on screen anyway. + */ + private static final int MAX_REMINDERS = 50; + + /** + * Redraws every widget that currently exists, and drops stored notes for widgets that no + * longer do. + * + * Everything else keys off what we have stored, which is fine while the app is running but + * leaves widgets showing content that no longer exists once the store is emptied underneath + * them (clearing app data) or a widget is removed while the app is stopped (onDeleted never + * arrives). Starting from the widgets the system knows about, rather than from our own data, + * is what makes this self-correcting. + * + * NoteWidget is left alone deliberately: it is a static button with no stored state, and its + * layout depends on the size it was last given. + */ + static void refreshAll(Context context) { + AppWidgetManager manager = AppWidgetManager.getInstance(context); + + int[] noteWidgetIds = manager.getAppWidgetIds( + new ComponentName(context, NotePreviewWidget.class)); + removeOrphanedNotes(context, noteWidgetIds); + for (int appWidgetId : noteWidgetIds) { + NotePreviewWidget.updateAppWidget(context, manager, appWidgetId); + } + + for (int appWidgetId : manager.getAppWidgetIds( + new ComponentName(context, ReminderWidgetProvider.class))) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders); + ReminderWidgetProvider.updateAppWidget(context, manager, appWidgetId, views); + } + } + + /** + * Drops stored notes whose widget is gone, so the preferences file cannot grow forever. + */ + private static void removeOrphanedNotes(Context context, int[] liveWidgetIds) { + Set live = new HashSet<>(); + for (int appWidgetId : liveWidgetIds) live.add(String.valueOf(appWidgetId)); + + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + SharedPreferences.Editor edit = preferences.edit(); + boolean changed = false; + + for (String key : preferences.getAll().keySet()) { + // Leave anything that is not a widget id alone, the reminders list included. + if (parseWidgetId(key) == null || live.contains(key)) continue; + edit.remove(key); + changed = true; + } + if (changed) edit.apply(); + } + + /** + * The note each note widget is showing, keyed by widget id. + * + * The preferences file mixes two things: one note per widget id, and the reminders list under + * its own key. Only numeric keys are widget notes, so anything else is skipped rather than + * being treated as a note. + */ + static Map getWidgetNotes(Context context) { + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + Map notes = new LinkedHashMap<>(); + + for (Map.Entry entry : preferences.getAll().entrySet()) { + Integer widgetId = parseWidgetId(entry.getKey()); + if (widgetId == null) continue; + if (!(entry.getValue() instanceof String)) continue; + + Note note = parseNote((String) entry.getValue()); + if (note == null || note.getId() == null) continue; + notes.put(widgetId, note); + } + return notes; + } + + /** + * The widget id a preferences key refers to, or null if the key is not a widget id at all. + */ + private static Integer parseWidgetId(String key) { + try { + return Integer.valueOf(key); + } catch (NumberFormatException e) { + return null; + } + } + + static Note parseNote(String data) { + if (data == null || data.isEmpty()) return null; + try { + return new Gson().fromJson(data, Note.class); + } catch (Exception e) { + Log.e("NotePreviewWidget", "Could not read a stored note", e); + return null; + } + } + + /** + * The reminders the app last wrote out, minus any that have now dropped out of view. Reading + * and filtering happens here so the provider can push the rows straight into the widget. + */ + static List getWidgetReminders(Context context) { + SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE); + List stored = null; + try { + stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"), + new TypeToken>() {}.getType()); + } catch (Exception e) { + Log.e("Reminders", "Could not read the stored reminders list", e); + } + + List active = new ArrayList<>(); + if (stored == null) return active; + + for (Reminder reminder : stored) { + if (!isVisibleInWidget(reminder)) continue; + if (active.size() >= MAX_REMINDERS) { + Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders"); + break; + } + active.add(reminder); + } + return active; + } + + /** + * Builds a single row of the reminders list. + */ + static RemoteViews createReminderItem(Context context, Reminder reminder) { + boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty(); + + RemoteViews views = new RemoteViews(context.getPackageName(), + useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout); + + views.setTextViewText(R.id.reminder_title, reminder.getTitle()); + if (!useMiniLayout) { + views.setTextViewText(R.id.reminder_description, reminder.getDescription()); + } + views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder)); + + Intent fillInIntent = new Intent(); + fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId())); + fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder"); + fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId()); + views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent); + return views; + } + + /** + * Options attached to the PendingIntents our widgets hand to the launcher, opting the creator + * (us) in to background activity starts so a tap on the widget can bring up an activity. + * + * MODE_BACKGROUND_ACTIVITY_START_ALLOWED is deprecated since API 36 and Android 17 extends the + * background activity launch restrictions to IntentSender, so on API 36+ we use the narrower + * MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE instead. That is enough for widgets: the + * sender is the launcher, which is visible whenever the user taps the widget. + */ + static Bundle getActivityOptionsBundle() { + ActivityOptions activityOptions; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + activityOptions = ActivityOptions.makeBasic(); + activityOptions.setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + activityOptions = ActivityOptions.makeBasic(); + activityOptions.setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); + } else { + return null; + } + return activityOptions.toBundle(); + } + + /** + * How long a reminder keeps its place in the list after going off, so the user can see that it + * happened rather than watching it vanish. Must match RECENTLY_PASSED_WINDOW in + * services/notifications.ts, which decides what gets written out in the first place. + */ + private static final long RECENTLY_PASSED_WINDOW_MS = TimeUnit.HOURS.toMillis(3); + + /** + * Whether a reminder should still be drawn. + * + * We re-check here rather than trusting the stored list because that list is only rewritten + * while the app runs. This is what actually retires a reminder once its grace period is up: + * every redraw re-evaluates it against the current time. + */ + static boolean isVisibleInWidget(Reminder reminder) { + if (reminder == null) return false; + if (reminder.isDisabled()) return false; + + long now = System.currentTimeMillis(); + if (reminder.getSnoozeUntil() > now) return true; + if (!"once".equals(reminder.getMode())) return true; + + long triggerDate = reminder.getTriggerDate() > 0 ? reminder.getTriggerDate() : reminder.getDate(); + return triggerDate > now - RECENTLY_PASSED_WINDOW_MS; + } + + /** + * Builds the label shown under a reminder. The app sends us the absolute trigger time plus the + * parts that never change ("5:00 PM", "12-05-2026, 5:00 PM"); everything that depends on the + * current time is decided here so it stays right as the widget redraws. + * + * Falls back to the pre-formatted string for lists written by an older version of the app. + */ + static String formatReminderTime(Context context, Reminder reminder) { + long triggerDate = reminder.getTriggerDate(); + String timeOfDay = reminder.getFormattedTimeOfDay(); + if (triggerDate <= 0 || timeOfDay == null || timeOfDay.isEmpty()) { + return reminder.getFormattedTime(); + } + + if ("permanent".equals(reminder.getMode())) { + return context.getString(R.string.reminder_ongoing); + } + + long now = System.currentTimeMillis(); + if (reminder.getSnoozeUntil() > now) { + return context.getString(R.string.reminder_snoozed_until, timeOfDay); + } + + String text; + long dayOffset = daysFromToday(triggerDate, now); + if (dayOffset == 0) { + text = context.getString(R.string.reminder_today, timeOfDay); + } else if (dayOffset == 1) { + text = context.getString(R.string.reminder_tomorrow, timeOfDay); + } else if (dayOffset == -1) { + text = context.getString(R.string.reminder_yesterday, timeOfDay); + } else { + text = reminder.getFormattedDateTime(); + if (text == null || text.isEmpty()) return reminder.getFormattedTime(); + } + + return context.getString( + triggerDate <= now ? R.string.reminder_last : R.string.reminder_upcoming, text); + } + + /** + * Calendar days between two instants. Compares midnights rather than subtracting the raw + * difference so that "tomorrow" is still tomorrow across a DST change or just before midnight. + */ + private static long daysFromToday(long time, long now) { + long target = startOfDay(time); + long today = startOfDay(now); + return Math.round((target - today) / (double) DateUtils.DAY_IN_MILLIS); + } + + private static long startOfDay(long time) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(time); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + return calendar.getTimeInMillis(); + } +} diff --git a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/datatypes/Reminder.java b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/datatypes/Reminder.java index 715199ca6..9b9b0fac6 100644 --- a/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/datatypes/Reminder.java +++ b/apps/mobile/android/app/src/main/java/com/streetwriters/notesnook/datatypes/Reminder.java @@ -2,13 +2,14 @@ package com.streetwriters.notesnook.datatypes; import androidx.annotation.Keep; -import java.util.concurrent.TimeUnit; - @Keep public class Reminder extends BaseItem { private String title; private String description; private String formattedTime; + private String formattedTimeOfDay; // e.g. "5:00 PM" + private String formattedDateTime; // e.g. "12-05-2026, 5:00 PM" + private long triggerDate; // absolute time this reminder next fires private String priority; // "silent", "vibrate", "urgent" private long date; private String mode; // "repeat", "once", "permanent" @@ -107,32 +108,27 @@ public class Reminder extends BaseItem { this.formattedTime = formattedTime; } - public String formatTime(long timeInMillis) { - long currentTime = System.currentTimeMillis(); - long diff = timeInMillis - currentTime; + public String getFormattedTimeOfDay() { + return formattedTimeOfDay; + } - if (diff < TimeUnit.MINUTES.toMillis(1)) { - return "in " + (diff / 1000) + " seconds"; - } else if (diff < TimeUnit.HOURS.toMillis(1)) { - long minutes = TimeUnit.MILLISECONDS.toMinutes(diff); - return "in " + minutes + " minute" + (minutes > 1 ? "s" : ""); - } else if (diff < TimeUnit.DAYS.toMillis(1)) { - long hours = TimeUnit.MILLISECONDS.toHours(diff); - return "in " + hours + " hour" + (hours > 1 ? "s" : ""); - } else if (diff < TimeUnit.DAYS.toMillis(2)) { - return "tomorrow"; - } else if (diff < TimeUnit.DAYS.toMillis(7)) { - long days = TimeUnit.MILLISECONDS.toDays(diff); - return "in " + days + " day" + (days > 1 ? "s" : ""); - } else if (diff < TimeUnit.DAYS.toMillis(30)) { - long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7; - return "in " + weeks + " week" + (weeks > 1 ? "s" : ""); - } else if (diff < TimeUnit.DAYS.toMillis(365)) { - long months = TimeUnit.MILLISECONDS.toDays(diff) / 30; - return "in " + months + " month" + (months > 1 ? "s" : ""); - } else { - long years = TimeUnit.MILLISECONDS.toDays(diff) / 365; - return "in " + years + " year" + (years > 1 ? "s" : ""); - } + public void setFormattedTimeOfDay(String formattedTimeOfDay) { + this.formattedTimeOfDay = formattedTimeOfDay; + } + + public String getFormattedDateTime() { + return formattedDateTime; + } + + public void setFormattedDateTime(String formattedDateTime) { + this.formattedDateTime = formattedDateTime; + } + + public long getTriggerDate() { + return triggerDate; + } + + public void setTriggerDate(long triggerDate) { + this.triggerDate = triggerDate; } } \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/drawable/layout_bg.xml b/apps/mobile/android/app/src/main/res/drawable/layout_bg.xml index 4e9108ff5..af55a9847 100644 --- a/apps/mobile/android/app/src/main/res/drawable/layout_bg.xml +++ b/apps/mobile/android/app/src/main/res/drawable/layout_bg.xml @@ -2,6 +2,6 @@ - + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/layout/new_note_widget.xml b/apps/mobile/android/app/src/main/res/layout/new_note_widget.xml index 622886bd1..70bb73b1b 100644 --- a/apps/mobile/android/app/src/main/res/layout/new_note_widget.xml +++ b/apps/mobile/android/app/src/main/res/layout/new_note_widget.xml @@ -2,8 +2,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@android:color/transparent" - android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer"> + android:background="@android:color/transparent"> + android:background="@android:color/transparent"> + android:text="@string/widget_note_unconfigured_title" /> + android:text="@string/widget_note_unconfigured_body" /> \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/layout/note_widget_preview.xml b/apps/mobile/android/app/src/main/res/layout/note_widget_preview.xml new file mode 100644 index 000000000..35c3cd7c6 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/layout/note_widget_preview.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/layout/widget_reminder_empty.xml b/apps/mobile/android/app/src/main/res/layout/widget_reminder_empty.xml deleted file mode 100644 index 9be87bb7a..000000000 --- a/apps/mobile/android/app/src/main/res/layout/widget_reminder_empty.xml +++ /dev/null @@ -1,8 +0,0 @@ - \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/layout/widget_reminders.xml b/apps/mobile/android/app/src/main/res/layout/widget_reminders.xml index f7f25bf55..10b723840 100644 --- a/apps/mobile/android/app/src/main/res/layout/widget_reminders.xml +++ b/apps/mobile/android/app/src/main/res/layout/widget_reminders.xml @@ -12,22 +12,23 @@ android:orientation="horizontal" android:paddingHorizontal="12dp" android:layout_gravity="center" - android:paddingTop="12dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" > + android:text="Reminders"/> @@ -57,7 +58,8 @@ android:layout_height="match_parent" android:textAlignment="center" android:gravity="center" - android:text="Tap on + to add reminder"/> + android:textColor="@color/text" + android:text="@string/widget_reminders_empty"/> diff --git a/apps/mobile/android/app/src/main/res/layout/widget_reminders_preview.xml b/apps/mobile/android/app/src/main/res/layout/widget_reminders_preview.xml new file mode 100644 index 000000000..92fd23a21 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/layout/widget_reminders_preview.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values-night/colors.xml b/apps/mobile/android/app/src/main/res/values-night/colors.xml index f1c79618b..3b836d0a6 100644 --- a/apps/mobile/android/app/src/main/res/values-night/colors.xml +++ b/apps/mobile/android/app/src/main/res/values-night/colors.xml @@ -1,9 +1,5 @@ - #FFE1F5FE - #FF81D4FA - #FF039BE5 - #FF01579B #1f1f1f #1D1D1D #2E2E2E diff --git a/apps/mobile/android/app/src/main/res/values-v31/dimens.xml b/apps/mobile/android/app/src/main/res/values-v31/dimens.xml new file mode 100644 index 000000000..c3e87c9e4 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-v31/dimens.xml @@ -0,0 +1,8 @@ + + + + + @android:dimen/system_app_widget_background_radius + + diff --git a/apps/mobile/android/app/src/main/res/values/attrs.xml b/apps/mobile/android/app/src/main/res/values/attrs.xml deleted file mode 100644 index 97531a256..000000000 --- a/apps/mobile/android/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml index 4ac8bc762..ab4d9ecc4 100644 --- a/apps/mobile/android/app/src/main/res/values/colors.xml +++ b/apps/mobile/android/app/src/main/res/values/colors.xml @@ -1,9 +1,5 @@ - #FFE1F5FE - #FF81D4FA - #FF039BE5 - #FF01579B #FFFFFF #DCEDEDED #BFBFBF diff --git a/apps/mobile/android/app/src/main/res/values/dimens.xml b/apps/mobile/android/app/src/main/res/values/dimens.xml index 4db8c5906..7aa2b16bc 100644 --- a/apps/mobile/android/app/src/main/res/values/dimens.xml +++ b/apps/mobile/android/app/src/main/res/values/dimens.xml @@ -7,4 +7,8 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout --> 0dp + + 10dp + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml index 1a233ee09..d5b719bcd 100644 --- a/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/apps/mobile/android/app/src/main/res/values/strings.xml @@ -4,9 +4,28 @@ EXAMPLE Add widget Take a quick note. - Quick overview of upcoming reminders + Quick overview of reminders Reminders Note Add a note to home screen Quick note + Tap + to add a reminder + + + Meeting notes + Discuss the roadmap and agree on timelines. + Take a walk + Upcoming: Today, 5:00 PM + Call the dentist + Upcoming: Tomorrow, 9:00 AM + + Tap to choose a note + Pick the note you want shown here. + Ongoing + Snoozed until %1$s + Today, %1$s + Tomorrow, %1$s + Yesterday, %1$s + Upcoming: %1$s + Last: %1$s diff --git a/apps/mobile/android/app/src/main/res/values/themes.xml b/apps/mobile/android/app/src/main/res/values/themes.xml deleted file mode 100644 index 935088e01..000000000 --- a/apps/mobile/android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml-v36/note_widget_info.xml b/apps/mobile/android/app/src/main/res/xml-v36/note_widget_info.xml new file mode 100644 index 000000000..97a47ff1a --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml-v36/note_widget_info.xml @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml-v36/widget_reminders_info.xml b/apps/mobile/android/app/src/main/res/xml-v36/widget_reminders_info.xml new file mode 100644 index 000000000..653f52e8f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/xml-v36/widget_reminders_info.xml @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml/new_note_widget_info.xml b/apps/mobile/android/app/src/main/res/xml/new_note_widget_info.xml index 086bf7228..c76d158b6 100644 --- a/apps/mobile/android/app/src/main/res/xml/new_note_widget_info.xml +++ b/apps/mobile/android/app/src/main/res/xml/new_note_widget_info.xml @@ -10,6 +10,7 @@ android:targetCellWidth="5" android:targetCellHeight="1" android:previewImage="@drawable/widget_preview" + android:previewLayout="@layout/new_note_widget" android:resizeMode="horizontal|vertical" android:updatePeriodMillis="86400000" android:widgetCategory="home_screen"/> \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml/note_widget_info.xml b/apps/mobile/android/app/src/main/res/xml/note_widget_info.xml index 84c936b29..c597dc450 100644 --- a/apps/mobile/android/app/src/main/res/xml/note_widget_info.xml +++ b/apps/mobile/android/app/src/main/res/xml/note_widget_info.xml @@ -12,6 +12,7 @@ android:targetCellWidth="5" android:targetCellHeight="1" android:previewImage="@drawable/note_widget_preview" + android:previewLayout="@layout/note_widget_preview" android:resizeMode="horizontal|vertical" android:updatePeriodMillis="86400000" android:widgetCategory="home_screen"/> \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/xml/widget_reminders_info.xml b/apps/mobile/android/app/src/main/res/xml/widget_reminders_info.xml index e9c87362f..175f46c39 100644 --- a/apps/mobile/android/app/src/main/res/xml/widget_reminders_info.xml +++ b/apps/mobile/android/app/src/main/res/xml/widget_reminders_info.xml @@ -1,14 +1,15 @@ \ No newline at end of file diff --git a/apps/mobile/android/releasenotes/whatsnew-en-US b/apps/mobile/android/releasenotes/whatsnew-en-US index 0454f523e..35eb621d9 100644 --- a/apps/mobile/android/releasenotes/whatsnew-en-US +++ b/apps/mobile/android/releasenotes/whatsnew-en-US @@ -1,4 +1,6 @@ -- Add option to clear note version history +- Added sync status icon in sidebar +- Added new reminder shortcut in app icon context menu +- Improved editor saving reliability - Minor bug fixes and improvements Thank you for using Notesnook! diff --git a/apps/mobile/app/app.tsx b/apps/mobile/app/app.tsx index 6cf5dd016..96b8a6fe7 100644 --- a/apps/mobile/app/app.tsx +++ b/apps/mobile/app/app.tsx @@ -23,7 +23,7 @@ import { THEME_COMPATIBILITY_VERSION, useThemeEngineStore } from "@notesnook/theme"; -import React, { PropsWithChildren, useEffect } from "react"; +import React, { PropsWithChildren, useEffect, useState } from "react"; import { Appearance, I18nManager, Linking, StatusBar } from "react-native"; import "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler"; @@ -44,26 +44,37 @@ import { useUserStore } from "./stores/use-user-store"; import RNBootSplash from "react-native-bootsplash"; import AppLocked from "./components/app-lock"; import { useSettingStore } from "./stores/use-setting-store"; +import { + initShortcutListener, + launchNewNoteTab, + registerAppShortcuts +} from "./hooks/use-shortcut-manager"; +import Shortcuts from "react-native-actions-shortcuts"; I18nManager.allowRTL(false); I18nManager.forceRTL(false); I18nManager.swapLeftAndRightInRTL(false); + const { appLockEnabled, appLockMode } = SettingsService.get(); if (appLockEnabled || appLockMode !== "none") { useUserStore.getState().lockApp(true); } -RNBootSplash.hide({ - fade: true -}); -Linking.getInitialURL().then((url) => { - useSettingStore.setState({ - initialUrl: url - }); -}); + const App = (props: { configureMode: "note-preview" }) => { useAppEvents(); //@ts-ignore globalThis["IS_MAIN_APP_RUNNING"] = true; + const introCompleted = useSettingStore( + (state) => state.settings.introCompleted + ); + useEffect(() => { + if (introCompleted) { + registerAppShortcuts(); + } + }, [introCompleted]); + + useEffect(() => { + RNBootSplash.hide({ fade: true }); SettingsService.onFirstLaunch(); changeSystemBarColors(); SettingsService.setPrivacyScreen( @@ -176,4 +187,41 @@ export const withTheme = ( }; }; -export default withTheme(withErrorBoundry(App, "App")); +export const withStartupBoundry = ( + Element: (props: PropsWithChildren) => JSX.Element +) => { + return function AppWithStartupBoundary(props: PropsWithChildren) { + const [ready, setReady] = useState(false); + + useEffect(() => { + async function init() { + try { + const [url, shortcut] = await Promise.all([ + Linking.getInitialURL(), + Shortcuts.getInitialShortcut() + ]); + console.log(url, shortcut); + if (shortcut?.type === "notesnook.action.newnote") { + launchNewNoteTab(); + } + useSettingStore.setState({ + initialUrl: url, + pendingShortcut: shortcut ?? null + }); + + initShortcutListener(); + } finally { + setReady(true); + } + } + + init(); + }, []); + + if (!ready) return null; + + return ; + }; +}; + +export default withStartupBoundry(withTheme(withErrorBoundry(App, "App"))); diff --git a/apps/mobile/app/components/dialog/index.tsx b/apps/mobile/app/components/dialog/index.tsx index c4e6a1080..3eaa4d092 100644 --- a/apps/mobile/app/components/dialog/index.tsx +++ b/apps/mobile/app/components/dialog/index.tsx @@ -143,9 +143,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => { }, [hide, show]); const onNegativePress = async () => { - if (dialogInfo?.onClose) { - await dialogInfo.onClose(); - } hide(); }; diff --git a/apps/mobile/app/components/fluid-panels/index.tsx b/apps/mobile/app/components/fluid-panels/index.tsx index c2c0d0372..45913a669 100644 --- a/apps/mobile/app/components/fluid-panels/index.tsx +++ b/apps/mobile/app/components/fluid-panels/index.tsx @@ -51,9 +51,10 @@ interface TabProps extends ViewProps { onScroll: (offset: number) => void; enabled: boolean; onDrawerStateChange: (state: boolean) => void; + initialPage?: FluidTabPage; } -type FluidTabPage = "home" | "editor"; +export type FluidTabPage = "home" | "editor"; export interface TabsRef { goToPage: (page: FluidTabPage, animated?: boolean) => void; @@ -77,15 +78,21 @@ export const FluidPanels = forwardRef(function FluidTabs( onChangeTab, onScroll, enabled, - onDrawerStateChange + onDrawerStateChange, + initialPage }: TabProps, ref ) { const deviceMode = useSettingStore((state) => state.deviceMode); const fullscreen = useSettingStore((state) => state.fullscreen); - const translateX = useSharedValue(widths ? widths.sidebar : 0); + const editorStartPosition = widths.sidebar + widths.list; + const translateX = useSharedValue( + initialPage === "editor" ? editorStartPosition : widths ? widths.sidebar : 0 + ); const startX = useSharedValue(0); - const currentTab = useSharedValue(1); + const currentTab = useSharedValue( + initialPage === "editor" && deviceMode !== "tablet" ? 2 : 1 + ); const previousTab = useSharedValue(1); const isDrawerOpen = useSharedValue(false); const gestureStartValue = useSharedValue({ diff --git a/apps/mobile/app/hooks/use-app-events.tsx b/apps/mobile/app/hooks/use-app-events.tsx index 91dbdde97..8b15aa470 100644 --- a/apps/mobile/app/hooks/use-app-events.tsx +++ b/apps/mobile/app/hooks/use-app-events.tsx @@ -81,6 +81,7 @@ import { setUpdateAvailableMessage } from "../services/message"; import Navigation from "../services/navigation"; +import { NotePreviewWidget } from "../services/note-preview-widget"; import Notifications from "../services/notifications"; import PremiumService from "../services/premium"; import SettingsService from "../services/settings"; @@ -575,6 +576,11 @@ export const useAppEvents = () => { useEffect(() => { if (isAppLoading) return; + // Widgets outlive the app process, so they can be left showing content the app no longer has + // (most obviously after the user clears app data). Nothing can run at that moment, so the + // first launch afterwards is the earliest chance to put them right. + NotePreviewWidget.updateNotes(); + let subscriptions: EventManagerSubscription[] = []; const eventManager = db.eventManager; subscriptions = [ diff --git a/apps/mobile/app/hooks/use-shortcut-manager.ts b/apps/mobile/app/hooks/use-shortcut-manager.ts index cb3ff2fee..9b990560c 100644 --- a/apps/mobile/app/hooks/use-shortcut-manager.ts +++ b/apps/mobile/app/hooks/use-shortcut-manager.ts @@ -17,54 +17,67 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ import Shortcuts, { ShortcutItem } from "react-native-actions-shortcuts"; -import { useEffect } from "react"; -import { NativeEventEmitter, NativeModule } from "react-native"; -import { useRef } from "react"; -import { Platform } from "react-native"; +import { NativeEventEmitter, NativeModule, Platform } from "react-native"; import deviceInfoModule from "react-native-device-info"; import { strings } from "@notesnook/intl"; +import { useSettingStore } from "../stores/use-setting-store"; +import { useTabStore } from "../screens/editor/tiptap/use-tab-store"; + const ShortcutsEmitter = new NativeEventEmitter( Shortcuts as unknown as NativeModule ); -function isSupported() { +export function isShortcutsSupported() { return Platform.OS !== "android" || deviceInfoModule.getApiLevelSync() > 25; } + const defaultShortcuts: ShortcutItem[] = [ { type: "notesnook.action.newnote", title: strings.createNewNote(), shortTitle: strings.newNote(), iconName: Platform.OS === "android" ? "ic_newnote" : "plus" + }, + { + type: "notesnook.action.newreminder", + title: strings.setReminder(), + shortTitle: strings.newReminder(), + iconName: Platform.OS === "android" ? "ic_newnote" : "plus" } ]; -export const useShortcutManager = ({ - onShortcutPressed, - shortcuts = defaultShortcuts -}: { - onShortcutPressed: (shortcut: ShortcutItem | null) => void; - shortcuts?: ShortcutItem[]; -}) => { - const initialShortcutRecieved = useRef(false); - useEffect(() => { - if (!isSupported()) return; - Shortcuts.setShortcuts(shortcuts); - }, [shortcuts]); +export function registerAppShortcuts( + shortcuts: ShortcutItem[] = defaultShortcuts +) { + if (!isShortcutsSupported()) return; + Shortcuts.setShortcuts(shortcuts); +} - useEffect(() => { - if (!isSupported()) return; - Shortcuts.getInitialShortcut().then((shortcut) => { - if (initialShortcutRecieved.current || !shortcut) return; - onShortcutPressed(shortcut); - initialShortcutRecieved.current = true; - }); - const subscription = ShortcutsEmitter.addListener( - "onShortcutItemPressed", - onShortcutPressed - ); - return () => { - subscription?.remove(); - }; - }, [onShortcutPressed]); -}; +let listenerInitialized = false; +export function initShortcutListener() { + if (!isShortcutsSupported() || listenerInitialized) return; + listenerInitialized = true; + ShortcutsEmitter.addListener( + "onShortcutItemPressed", + (shortcut: ShortcutItem) => { + console.time("shortcut"); + useSettingStore.setState({ pendingShortcut: shortcut }); + } + ); +} + +export function launchNewNoteTab() { + let tabId; + const currentTab = useTabStore + .getState() + .getTab(useTabStore.getState().currentTab as string); + + if (useTabStore.getState().tabs.length === 0 || currentTab?.pinned) { + tabId = useTabStore.getState().newTab(); + } else { + tabId = useTabStore.getState().currentTab; + if (useTabStore.getState().getTab(tabId)?.session?.noteId) { + useTabStore.getState().newTabSession(tabId, {}); + } + } +} diff --git a/apps/mobile/app/navigation/fluid-panels-view.tsx b/apps/mobile/app/navigation/fluid-panels-view.tsx index 2ef17c040..e2650aef9 100644 --- a/apps/mobile/app/navigation/fluid-panels-view.tsx +++ b/apps/mobile/app/navigation/fluid-panels-view.tsx @@ -41,10 +41,9 @@ import Animated, { } from "react-native-reanimated"; import { notesnook } from "../../e2e/test.ids"; import { db } from "../common/database"; -import { FluidPanels } from "../components/fluid-panels"; +import { FluidPanels, FluidTabPage } from "../components/fluid-panels"; import { useSideBarDraggingStore } from "../components/side-menu/dragging-store"; import useGlobalSafeAreaInsets from "../hooks/use-global-safe-area-insets"; -import { useShortcutManager } from "../hooks/use-shortcut-manager"; import { hideAllTooltips } from "../hooks/use-tooltip"; import { useTabStore } from "../screens/editor/tiptap/use-tab-store"; import { editorController, editorState } from "../screens/editor/tiptap/utils"; @@ -59,7 +58,6 @@ import { eCloseFullscreenEditor, eOnEnterEditor, eOnExitEditor, - eOnLoadNote, eOpenFullscreenEditor, eUnlockNote } from "../utils/events"; @@ -67,6 +65,7 @@ import { valueLimiter } from "../utils/functions"; import { fluidTabsRef } from "../utils/global-refs"; import { AppNavigationStack } from "./navigation-stack"; import type { PaneWidths } from "../screens/editor/wrapper"; +import { NavigationProps } from "../services/navigation"; const MOBILE_SIDEBAR_SIZE = 0.85; @@ -74,7 +73,7 @@ let SideMenu: any = null; let EditorWrapper: any = null; export const FluidPanelsView = React.memo( - () => { + ({ route }: NavigationProps<"FluidPanelsView">) => { const { colors } = useThemeColors(); const deviceMode = useSettingStore((state) => state.deviceMode); const setFullscreen = useSettingStore((state) => state.setFullscreen); @@ -102,6 +101,15 @@ export const FluidPanelsView = React.memo( setOrientation(o); } }); + React.useEffect(() => { + const shortcut = useSettingStore.getState().pendingShortcut; + + if (shortcut?.type === "notesnook.action.newnote") { + useSettingStore.setState({ + pendingShortcut: null + }); + } + }, []); useEffect(() => { if (!appLoading) { @@ -111,29 +119,6 @@ export const FluidPanelsView = React.memo( } }, [appLoading]); - useShortcutManager({ - onShortcutPressed: async (item) => { - if (!item) return; - - if (item?.type === "notesnook.action.newnote") { - if (!fluidTabsRef.current) { - setTimeout(() => { - eSendEvent(eOnLoadNote, { newNote: true }); - editorState().movedAway = false; - fluidTabsRef.current?.goToPage("editor", false); - }, 1000); - return; - } - eSendEvent(eOnLoadNote, { newNote: true }); - editorState().movedAway = false; - setTimeout( - () => fluidTabsRef.current?.goToPage("editor", false), - 300 - ); - } - } - }); - const showFullScreenEditor = useCallback(() => { setFullscreen(true); if (deviceMode === "smallTablet") { @@ -356,6 +341,7 @@ export const FluidPanelsView = React.memo( dimensions={dimensions} widths={PANE_WIDTHS[deviceMode as keyof typeof PANE_WIDTHS]} enabled={deviceMode !== "tablet" && !fullscreen} + initialPage={route.params?.initialPage} onScroll={onScroll} onChangeTab={onChangeTab} onDrawerStateChange={(state) => { diff --git a/apps/mobile/app/navigation/navigation-stack.tsx b/apps/mobile/app/navigation/navigation-stack.tsx index 570b7e1c8..486b4150c 100644 --- a/apps/mobile/app/navigation/navigation-stack.tsx +++ b/apps/mobile/app/navigation/navigation-stack.tsx @@ -27,10 +27,17 @@ import useNavigationStore, { } from "../stores/use-navigation-store"; import { useSelectionStore } from "../stores/use-selection-store"; import { useSettingStore } from "../stores/use-setting-store"; -import { rootNavigatorRef } from "../utils/global-refs"; +import { fluidTabsRef, rootNavigatorRef } from "../utils/global-refs"; import Navigation from "../services/navigation"; -import { isFeatureAvailable } from "@notesnook/common"; +import { isFeatureAvailable, useIsFeatureAvailable } from "@notesnook/common"; import { isInternalLink, parseInternalLink } from "@notesnook/core"; +import { eSendEvent } from "../services/event-manager"; +import { editorState } from "../screens/editor/tiptap/utils"; +import { eOnLoadNote } from "../utils/events"; +import { strings } from "@notesnook/intl"; +import PaywallSheet from "../components/sheets/paywall"; +import { presentDialog } from "../components/dialog/functions"; +import { launchNewNoteTab } from "../hooks/use-shortcut-manager"; const RootStack = createNativeStackNavigator(); const AppStack = createNativeStackNavigator(); @@ -300,8 +307,15 @@ export const RootNavigation = () => { const introCompleted = useSettingStore( (state) => state.settings.introCompleted ); + + const initialShortcut = React.useRef( + useSettingStore.getState().pendingShortcut + ).current; + + const reminderFeature = useIsFeatureAvailable("activeReminders"); const clearSelection = useSelectionStore((state) => state.clearSelection); const resetTimer = React.useRef(undefined); + const onStateChange = React.useCallback( (state: any) => { if (useSelectionStore.getState().selectionMode) { @@ -316,13 +330,66 @@ export const RootNavigation = () => { [clearSelection] ); + React.useEffect(() => { + const unsubscribe = useSettingStore.subscribe((state, prevState) => { + const pendingShortcut = state.pendingShortcut; + + if (pendingShortcut === prevState.pendingShortcut || !pendingShortcut) { + return; + } + + if (pendingShortcut.type === "notesnook.action.newreminder") { + if (reminderFeature === undefined) return; + + if (!reminderFeature.isAllowed) { + presentDialog({ + title: strings.upgrade(), + paragraph: reminderFeature.error, + positiveText: strings.upgrade(), + negativeText: strings.cancel(), + positivePress: async () => { + PaywallSheet.present(reminderFeature); + } + }); + useSettingStore.setState({ + pendingShortcut: null + }); + return; + } + + rootNavigatorRef.current?.navigate("AddReminder" as any); + } else if (pendingShortcut.type === "notesnook.action.newnote") { + if (fluidTabsRef.current) { + rootNavigatorRef.current?.navigate("FluidPanelsView" as any); + eSendEvent(eOnLoadNote, { newNote: true }); + editorState().movedAway = false; + fluidTabsRef.current.goToPage("editor", true); + } else { + launchNewNoteTab(); + + rootNavigatorRef.current?.navigate("FluidPanelsView" as any, { + initialPage: "editor" + }); + } + } + }); + + return unsubscribe; + }, [reminderFeature]); + + const initialRouteName = !introCompleted + ? "Welcome" + : initialShortcut?.type === "notesnook.action.newreminder" + ? "AddReminder" + : "FluidPanelsView"; + return ( { require("../navigation/fluid-panels-view").default; return FluidPanelsView; }} + initialParams={{ + initialPage: + initialShortcut?.type === "notesnook.action.newnote" + ? "editor" + : undefined + }} /> ) { - const { reminder, reference } = props.route.params; + const { reminder, reference } = props.route.params ?? {}; useNavigationFocus(props.navigation, { focusOnInit: true, onFocus: () => { @@ -106,6 +108,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) { return false; } }); + const handleBackNavigation = useCallback(() => { + const routes = props.navigation.getState()?.routes; + if (routes && routes.length <= 1) { + props.navigation.navigate("FluidPanelsView" as any); + return true; + } + Navigation.goBack(); + return true; + }, [props.navigation]); + + useEffect(() => { + const sub = BackHandler.addEventListener("hardwareBackPress", () => { + return handleBackNavigation(); + }); + return () => sub.remove(); + }, [handleBackNavigation]); + const { colors, isDark } = useThemeColors(); const weekFormat = useSettingStore((state) => state.weekFormat); const [reminderMode, setReminderMode] = useState( @@ -127,6 +146,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) { const [repeatFrequency, setRepeatFrequency] = useState(1); const referencedItem = reference ? (reference as Note) : null; const recurringReminderFeature = useIsFeatureAvailable("recurringReminders"); + const activeReminderFeature = useIsFeatureAvailable("activeReminders"); const formRef = useRef( createFormRef({ title: reminder?.title || referencedItem?.title || "", @@ -153,6 +173,31 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) { ); const [dateError, setDateError] = useState(); const [selectDayError, setSelectDayError] = useState(); + React.useEffect(() => { + const shortcut = useSettingStore.getState().pendingShortcut; + if (shortcut?.type === "notesnook.action.newreminder") { + useSettingStore.setState({ + pendingShortcut: null + }); + } + }, []); + useEffect(() => { + if (activeReminderFeature === undefined) return; + if (!activeReminderFeature.isAllowed) { + presentDialog({ + title: strings.upgrade(), + paragraph: activeReminderFeature.error, + positiveText: strings.upgrade(), + negativeText: strings.cancel(), + positivePress: async () => { + PaywallSheet.present(activeReminderFeature); + }, + onClose: () => { + props.navigation.navigate("FluidPanelsView" as any); + } + }); + } + }, [activeReminderFeature]); const showDatePicker = () => { setDatePickerVisibility(true); @@ -243,7 +288,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) { Notifications.scheduleNotification(_reminder as Reminder); Navigation.queueRoutesForUpdate(); useRelationStore.getState().update(); - Navigation.goBack(); + handleBackNavigation(); } catch (e) { ToastManager.error(e as Error, undefined); } @@ -267,12 +312,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
- { } }; -type ContentMessage = { html: string; ignoreEdit: boolean }; +type ContentMessage = { html: string }; export const useEditorEvents = ( editor: useEditorType, @@ -411,16 +411,19 @@ export const useEditorEvents = ( .getState() .getNoteIdForTab(editorMessage.tabId); + const saveNoteId = editorMessage.noteId || noteId; + switch (editorMessage.type) { case EditorEvents.content: DatabaseLogger.log("EditorEvents.content"); editor.saveContent({ type: editorMessage.type, content: editorMessage.value.html as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, - ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.title: @@ -428,10 +431,11 @@ export const useEditorEvents = ( editor.saveContent({ type: editorMessage.type, title: editorMessage.value?.title as string, - noteId: noteId, + noteId: saveNoteId, + sourceNoteId: editorMessage.noteId, tabId: editorMessage.tabId, - ignoreEdit: false, - pendingChanges: editorMessage.value?.pendingChanges + pendingChanges: editorMessage.value?.pendingChanges, + pendingChangesAt: editorMessage.value?.pendingChangesAt }); break; case EditorEvents.logger: diff --git a/apps/mobile/app/screens/editor/tiptap/use-editor.ts b/apps/mobile/app/screens/editor/tiptap/use-editor.ts index 830331751..53776bbca 100644 --- a/apps/mobile/app/screens/editor/tiptap/use-editor.ts +++ b/apps/mobile/app/screens/editor/tiptap/use-editor.ts @@ -260,29 +260,65 @@ export const useEditor = ( id, data, type, - ignoreEdit, sessionHistoryId: currentSessionHistoryId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: SavePayload) => { if (currentNotes.current[id as string]?.readonly || readonly) return; + + if (sourceNoteId && id && sourceNoteId !== id) { + DatabaseLogger.error( + new Error( + `Refused to save content of note ${sourceNoteId} into note ${id}` + ) + ); + return; + } + try { if (id && !(await db.notes?.note(id))) { - await reset(tabId); - useTabStore.getState().updateTab(tabId, { - session: { - noteId: undefined, - noteLocked: undefined, - locked: undefined, - readonly: undefined, - scrollTop: undefined, - selection: undefined, - spellCheckDisabled: false - } - }); + if (useTabStore.getState().getNoteIdForTab(tabId) === id) { + await reset(tabId); + useTabStore.getState().updateTab(tabId, { + session: { + noteId: undefined, + noteLocked: undefined, + locked: undefined, + readonly: undefined, + scrollTop: undefined, + selection: undefined, + spellCheckDisabled: false + } + }); + } return; } let note = id ? await db.notes?.note(id) : undefined; + + // A restored pending change can be older than what is already in the + // db (it was saved on another device, or the save actually went + // through and only the acknowledgement was lost). Applying it would + // roll the note back, so verify it is still the newest edit. Content + // and title are compared separately so that a newer title doesn't + // discard pending content, and vice versa. + if (pendingChanges && pendingChangesAt && note) { + const dateEdited = data + ? note.contentId + ? (await db.content?.get(note.contentId))?.dateEdited + : undefined + : note.dateEdited; + + if (dateEdited && dateEdited > pendingChangesAt) { + DatabaseLogger.log( + `Discarding stale pending ${ + data ? "content" : "title" + } for note ${id}: edited at ${dateEdited}, change captured at ${pendingChangesAt}` + ); + return id; + } + } const locked = note && (await db.vaults.itemExists(note)); if (note?.conflicted) { @@ -306,11 +342,6 @@ export const useEditor = ( noteData.title = title; - if (ignoreEdit) { - DatabaseLogger.log("Ignoring edits..."); - noteData.dateEdited = note?.dateEdited; - } - if (data) { noteData.content = { data: data, @@ -321,6 +352,9 @@ export const useEditor = ( let saved = false; setTimeout(() => { if (saved) return; + // Don't report progress on a tab that has moved on to another note. + if (id && useTabStore.getState().getNoteIdForTab(tabId) !== id) + return; commands.setStatus( getFormattedDate(note ? note.dateEdited : Date.now(), "date-time"), strings.saving(), @@ -436,14 +470,26 @@ export const useEditor = ( } } - if ( - id && - id === useTabStore.getState().getCurrentNoteId() && - pendingChanges - ) { - postMessage(NativeEvents.title, title || note?.title, tabId); - postMessage(NativeEvents.html, data, tabId); - currentNotes.current[id] = note; + if (id && pendingChanges) { + if (data) { + currentContents.current[id] = { + data: data, + type: "tiptap", + noteId: id + }; + } + lastContentChangeTime.current[id] = Date.now(); + + // Push the restored change into the editor only if the note is + // actually open in a tab, and only into that tab. + const noteTabId = useTabStore.getState().getTabForNote(id); + if (noteTabId !== undefined) { + postMessage(NativeEvents.title, title || note?.title, noteTabId); + if (data) { + postMessage(NativeEvents.html, { data: data }, noteTabId); + } + currentNotes.current[id] = note; + } } if (!saveCount.current[tabId]) { @@ -932,24 +978,25 @@ export const useEditor = ( title, content, type, - ignoreEdit, noteId, tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }: { noteId?: string; title?: string; content?: string; type: string; - ignoreEdit: boolean; tabId: string; pendingChanges?: boolean; + sourceNoteId?: string; + pendingChangesAt?: number; }) => { DatabaseLogger.log( `saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}` ); if ( - ignoreEdit || lock.current || (currentLoadingNoteId.current && currentLoadingNoteId.current === noteId) @@ -958,7 +1005,6 @@ export const useEditor = ( lock.current: ${lock.current} currentLoadingNoteId.current: ${currentLoadingNoteId.current} - ignoreEdit: ${ignoreEdit} `); if (lock.current) { setTimeout(() => { @@ -971,7 +1017,10 @@ export const useEditor = ( return; } - if (noteId) { + // A restored pending change is not a live edit: it may still be + // discarded as stale by saveNote, so it must not claim to be the newest + // content until it is actually written. + if (noteId && !pendingChanges) { lastContentChangeTime.current[noteId] = Date.now(); localTabState.current?.setEditTime(noteId, Date.now()); localTabState?.current?.set(tabId, { @@ -979,7 +1028,7 @@ export const useEditor = ( }); } - if (type === EditorEvents.content && noteId) { + if (type === EditorEvents.content && noteId && !pendingChanges) { currentContents.current[noteId as string] = { data: content, type: "tiptap", @@ -992,15 +1041,17 @@ export const useEditor = ( data: content, type: "tiptap", id: noteId, - ignoreEdit, sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined, tabId: tabId, - pendingChanges + pendingChanges, + sourceNoteId, + pendingChangesAt }; + withTimer( - noteId || "newnote", + `${noteId || tabId}:${type}`, () => { - if (!params.id) { + if (!params.id && !params.sourceNoteId) { params.id = useTabStore.getState().getNoteIdForTab(tabId); } if (onChange && params.data) { @@ -1018,7 +1069,7 @@ export const useEditor = ( saveNote(params); } }, - ignoreEdit ? 0 : 150 + 150 ); }, [editorSessionHistory, withTimer, onChange, saveNote] diff --git a/apps/mobile/app/screens/home/index.tsx b/apps/mobile/app/screens/home/index.tsx index 46c4fa111..bdb556c49 100755 --- a/apps/mobile/app/screens/home/index.tsx +++ b/apps/mobile/app/screens/home/index.tsx @@ -30,6 +30,7 @@ import SettingsService from "../../services/settings"; import useNavigationStore from "../../stores/use-navigation-store"; import { useNotes } from "../../stores/use-notes-store"; import { openEditor } from "../notes/common"; +import { db } from "../../common/database"; export const Home = ({ navigation, route }: NavigationProps<"Notes">) => { const [notes, loading] = useNotes(); @@ -59,7 +60,8 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => { placeholder: strings.searchInRoute(route.name), type: "note", title: route.name, - route: route.name + route: route.name, + items: db.notes.all }); }} id={route.name} diff --git a/apps/mobile/app/services/navigation.ts b/apps/mobile/app/services/navigation.ts index f3b4114d0..4bcc58eb9 100755 --- a/apps/mobile/app/services/navigation.ts +++ b/apps/mobile/app/services/navigation.ts @@ -177,9 +177,11 @@ function resetRootState( if (state.routes.length < 2) return; - const routes = state.routes.filter( + let routes = state.routes.filter( (route) => - (route.name !== "Auth" && route.name !== "Welcome") || + (route.name !== "Auth" && + route.name !== "Welcome" && + route.name !== "AddReminder") || route.key === focusedRoute.key ); diff --git a/apps/mobile/app/services/note-preview-widget.ts b/apps/mobile/app/services/note-preview-widget.ts index 8f7610732..cbb259e6a 100644 --- a/apps/mobile/app/services/note-preview-widget.ts +++ b/apps/mobile/app/services/note-preview-widget.ts @@ -34,6 +34,10 @@ export const NotePreviewWidget = { NotesnookModule.updateWidgetNote(id, JSON.stringify(newNote)); } + // Redraw from the widgets that actually exist rather than only the ones we + // have notes for. After app data is cleared there are none, and the widgets + // would otherwise keep showing content that no longer exists. + NotesnookModule.refreshWidgets(); }, 500); }, updateNote: async (id: string, note: Note) => { diff --git a/apps/mobile/app/services/notifications.ts b/apps/mobile/app/services/notifications.ts index 73ffa8e45..6dd620b5c 100644 --- a/apps/mobile/app/services/notifications.ts +++ b/apps/mobile/app/services/notifications.ts @@ -17,8 +17,12 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { getFormattedReminderTime } from "@notesnook/common"; -import { isReminderActive, Reminder } from "@notesnook/core"; +import { getFormattedDate, getFormattedReminderTime } from "@notesnook/common"; +import { + getUpcomingReminderTime, + isReminderActive, + Reminder +} from "@notesnook/core"; import { strings } from "@notesnook/intl"; import notifee, { AndroidStyle, @@ -262,8 +266,13 @@ const onEvent = async ({ type, detail }: Event) => { type ReminderWithFormattedTime = Reminder & { formattedTime?: string; + triggerDate?: number; + formattedTimeOfDay?: string; + formattedDateTime?: string; }; +const RECENTLY_PASSED_WINDOW = 3 * 60 * 60 * 1000; + async function updateRemindersForWidget() { if (Platform.OS === "ios") return; const reminders: ReminderWithFormattedTime[] = await db.reminders?.all.items( @@ -273,18 +282,33 @@ async function updateRemindersForWidget() { sortDirection: "asc" } ); - const activeReminders = []; + const widgetReminders = []; if (!reminders) return; for (const reminder of reminders) { - if (isReminderActive(reminder)) { - reminder.formattedTime = getFormattedReminderTime(reminder); - activeReminders.push(reminder); - } + const triggerDate = + reminder.snoozeUntil && reminder.snoozeUntil > Date.now() + ? reminder.snoozeUntil + : reminder.mode === "repeat" + ? getUpcomingReminderTime(reminder) + : reminder.date; + + const recentlyPassed = + reminder.mode === "once" && + !reminder.disabled && + triggerDate > Date.now() - RECENTLY_PASSED_WINDOW; + + if (!isReminderActive(reminder) && !recentlyPassed) continue; + + reminder.triggerDate = triggerDate; + reminder.formattedTimeOfDay = getFormattedDate(triggerDate, "time"); + reminder.formattedDateTime = getFormattedDate(triggerDate, "date-time"); + reminder.formattedTime = getFormattedReminderTime(reminder); + widgetReminders.push(reminder); } NotesnookModule.setString( "appPreview", "remindersList", - JSON.stringify(activeReminders) + JSON.stringify(widgetReminders) ); NotesnookModule.updateReminderWidget(); } diff --git a/apps/mobile/app/stores/use-navigation-store.ts b/apps/mobile/app/stores/use-navigation-store.ts index e3b1ff5c6..7adfbdc67 100644 --- a/apps/mobile/app/stores/use-navigation-store.ts +++ b/apps/mobile/app/stores/use-navigation-store.ts @@ -74,13 +74,21 @@ export interface RouteParams extends ParamListBase { Tags: GenericRouteParam; Favorites: GenericRouteParam; Trash: GenericRouteParam; - Search: { - placeholder: string; - type: ItemType; - title: string; - route: RouteName; - items?: FilteredSelector; - }; + Search: + | { + placeholder: string; + type: "note"; + title: string; + route: RouteName; + items: FilteredSelector; + } + | { + placeholder: string; + type: Exclude; + title: string; + route: RouteName; + items?: FilteredSelector; + }; TaggedNotes: NotesScreenParams; ColoredNotes: NotesScreenParams; TopicNotes: NotesScreenParams; @@ -88,7 +96,7 @@ export interface RouteParams extends ParamListBase { Monographs: NotesScreenParams; Reminders: GenericRouteParam; SettingsGroup: GenericRouteParam; - FluidPanelsView: GenericRouteParam; + FluidPanelsView: { initialPage?: "editor" | "home" }; AppLock: GenericRouteParam; Settings: GenericRouteParam; Auth: AuthParams; diff --git a/apps/mobile/app/stores/use-setting-store.ts b/apps/mobile/app/stores/use-setting-store.ts index 6fc0f67f7..ea5aa5c28 100644 --- a/apps/mobile/app/stores/use-setting-store.ts +++ b/apps/mobile/app/stores/use-setting-store.ts @@ -27,6 +27,7 @@ import { ThemeDark, ThemeLight, ThemeDefinition } from "@notesnook/theme"; import { DayFormat, WeekFormat, Reminder } from "@notesnook/core"; import { db } from "../common/database"; import { EDITOR_LINE_HEIGHT } from "../utils/constants"; +import { ShortcutItem } from "react-native-actions-shortcuts"; export const HostIds = [ "API_HOST", "AUTH_HOST", @@ -149,6 +150,7 @@ export interface SettingStore { refresh: () => void; inboxEnabled: boolean; setInboxEnabled: (inboxEnabled: boolean) => void; + pendingShortcut: ShortcutItem | null; } const { width, height } = Dimensions.get("window"); @@ -269,5 +271,6 @@ export const useSettingStore = create((set, get) => ({ }); }, inboxEnabled: false, - setInboxEnabled: (inboxEnabled) => set({ inboxEnabled }) + setInboxEnabled: (inboxEnabled) => set({ inboxEnabled }), + pendingShortcut: null })); diff --git a/apps/mobile/app/utils/notesnook-module.ts b/apps/mobile/app/utils/notesnook-module.ts index d682e3f28..0d90c4681 100644 --- a/apps/mobile/app/utils/notesnook-module.ts +++ b/apps/mobile/app/utils/notesnook-module.ts @@ -45,6 +45,7 @@ interface NotesnookModuleInterface { hasWidgetNote: (noteId: string) => Promise; updateWidgetNote: (noteId: string, data: string) => void; updateReminderWidget: () => void; + refreshWidgets: () => void; isGestureNavigationEnabled: () => boolean; addShortcut: ( id: string, @@ -81,6 +82,7 @@ export const NotesnookModule: NotesnookModuleInterface = Platform.select({ hasWidgetNote: () => {}, updateWidgetNote: () => {}, updateReminderWidget: () => {}, + refreshWidgets: () => {}, isGestureNavigationEnabled: () => true, addShortcut: () => Promise.resolve(false), removeShortcut: () => Promise.resolve(false), diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile index 5e28ff61a..a1cdf9023 100644 --- a/apps/mobile/ios/Podfile +++ b/apps/mobile/ios/Podfile @@ -5,6 +5,8 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip +require_relative 'scripts/patch_fmt_consteval' + platform :ios, min_ios_version_supported prepare_react_native_project! @@ -66,6 +68,10 @@ post_install do |installer| :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + # Keep fmt buildable on Xcode >= 26.2. See ios/scripts/patch_fmt_consteval.rb. + PatchFmtConsteval.apply!(installer.sandbox.root) + installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['APPLICATION_EXTENSION_API_ONLY'] = 'NO' diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 177862349..d576a454d 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -4092,6 +4092,6 @@ SPEC CHECKSUMS: toolbar-android: c426ed5bd3dcccfed20fd79533efc0d1ae0ef018 Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb -PODFILE CHECKSUM: 3fe13efa8356dcc061862bfa9f453dcd12ede70a +PODFILE CHECKSUM: 30b2045c0f4fc91402a43a9e2a872af803f2d6c3 COCOAPODS: 1.16.2 diff --git a/apps/mobile/ios/build-configs/ios-build.active.xcconfig b/apps/mobile/ios/build-configs/ios-build.active.xcconfig index 4d0ea7252..e03bda205 100644 --- a/apps/mobile/ios/build-configs/ios-build.active.xcconfig +++ b/apps/mobile/ios/build-configs/ios-build.active.xcconfig @@ -1,6 +1,6 @@ // Production iOS build identifiers -IOS_CURRENT_PROJECT_VERSION = 2191 -IOS_MARKETING_VERSION = 3.4.7 +IOS_CURRENT_PROJECT_VERSION = 2192 +IOS_MARKETING_VERSION = 3.4.8 IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share diff --git a/apps/mobile/ios/build-configs/ios-build.production.xcconfig b/apps/mobile/ios/build-configs/ios-build.production.xcconfig index 4d0ea7252..e03bda205 100644 --- a/apps/mobile/ios/build-configs/ios-build.production.xcconfig +++ b/apps/mobile/ios/build-configs/ios-build.production.xcconfig @@ -1,6 +1,6 @@ // Production iOS build identifiers -IOS_CURRENT_PROJECT_VERSION = 2191 -IOS_MARKETING_VERSION = 3.4.7 +IOS_CURRENT_PROJECT_VERSION = 2192 +IOS_MARKETING_VERSION = 3.4.8 IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share diff --git a/apps/mobile/ios/build-configs/ios-build.staging.xcconfig b/apps/mobile/ios/build-configs/ios-build.staging.xcconfig index 2404c2515..b2efe4bf3 100644 --- a/apps/mobile/ios/build-configs/ios-build.staging.xcconfig +++ b/apps/mobile/ios/build-configs/ios-build.staging.xcconfig @@ -1,6 +1,6 @@ // Staging iOS build identifiers -IOS_CURRENT_PROJECT_VERSION = 2191 -IOS_MARKETING_VERSION = 3.4.7 +IOS_CURRENT_PROJECT_VERSION = 2192 +IOS_MARKETING_VERSION = 3.4.8 IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share diff --git a/apps/mobile/ios/scripts/patch_fmt_consteval.rb b/apps/mobile/ios/scripts/patch_fmt_consteval.rb new file mode 100644 index 000000000..eb28baf08 --- /dev/null +++ b/apps/mobile/ios/scripts/patch_fmt_consteval.rb @@ -0,0 +1,77 @@ +# Xcode >= 26.2 rejects fmt's compile-time format-string check with +# +# call to consteval function 'fmt::fstring<...>::fstring' is not a +# constant expression +# +# React Native 0.81/0.82 vendor fmt 11.0.2, which hits this. It is an upstream +# incompatibility (reproducible in a stock RN app), but left alone it makes those +# RN versions permanently un-buildable on a modern Xcode. +# +# fmt's own escape hatch is FMT_USE_CONSTEVAL: 0 downgrades the format-string +# check from compile-time to run-time. fmt 11.0.2 does not guard its detection +# block with #ifndef, so we cannot simply predefine the macro -- and doing it +# through the build settings is worse anyway: +# +# * a command-line GCC_PREPROCESSOR_DEFINITIONS outranks every per-target +# value, silently dropping COCOAPODS=1, RCT_METRO_PORT, ... +# * a second `post_install` block in the Podfile REPLACES React Native's own. +# +# So we patch the header itself, right after fmt has made up its mind and before +# the first use of the macro. Idempotent, and safe to run on every pod install. + +module PatchFmtConsteval + MARKER = 'NOTESNOOK_FMT_CONSTEVAL_PATCH'.freeze + + # The line that first consumes the macro; our override goes immediately above + # it, i.e. after the whole detection cascade. + ANCHOR = "#if FMT_USE_CONSTEVAL\n".freeze + + OVERRIDE = <<~PATCH.freeze + // #{MARKER}: Xcode >= 26.2 rejects fmt's consteval format-string check + // (see ios/scripts/patch_fmt_consteval.rb). Applied automatically by + // `pod install`; downgrades the check to run-time. + #undef FMT_USE_CONSTEVAL + #define FMT_USE_CONSTEVAL 0 + PATCH + + # pods_root: the Pods directory (installer.sandbox.root). + def self.apply!(pods_root) + header = File.join(pods_root.to_s, 'fmt', 'include', 'fmt', 'base.h') + + unless File.exist?(header) + Pod::UI.warn "fmt: #{header} not found, skipping consteval patch." + return + end + + contents = File.read(header) + + if contents.include?(MARKER) + Pod::UI.puts 'fmt: consteval patch already applied.' + return + end + + index = contents.index(ANCHOR) + if index.nil? + Pod::UI.warn 'fmt: could not find `#if FMT_USE_CONSTEVAL` in base.h; ' \ + 'the consteval patch was NOT applied. If this fmt version ' \ + 'still uses a consteval format-string check, builds on ' \ + 'Xcode >= 26.2 will fail -- update ' \ + 'ios/scripts/patch_fmt_consteval.rb.' + return + end + + contents.insert(index, OVERRIDE) + + # CocoaPods checks pod sources out read-only (0444), so make the header + # writable for the write and restore the original mode afterwards. + mode = File.stat(header).mode & 0o7777 + begin + File.chmod(mode | 0o200, header) + File.write(header, contents) + ensure + File.chmod(mode, header) + end + + Pod::UI.puts 'fmt: patched base.h to set FMT_USE_CONSTEVAL=0 (Xcode >= 26.2 compatibility).' + end +end diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 50b7339e0..b5fd34034 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@notesnook/mobile", - "version": "3.4.7", + "version": "3.4.8", "private": true, "license": "GPL-3.0-or-later", "scripts": { diff --git a/fastlane/metadata/android/en-US/changelogs/15574.txt b/fastlane/metadata/android/en-US/changelogs/15574.txt new file mode 100644 index 000000000..35eb621d9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/15574.txt @@ -0,0 +1,6 @@ +- Added sync status icon in sidebar +- Added new reminder shortcut in app icon context menu +- Improved editor saving reliability +- Minor bug fixes and improvements + +Thank you for using Notesnook! diff --git a/packages/core/src/api/lookup.ts b/packages/core/src/api/lookup.ts index d1ae43f00..2096d10b8 100644 --- a/packages/core/src/api/lookup.ts +++ b/packages/core/src/api/lookup.ts @@ -114,7 +114,6 @@ export default class Lookup { ): Promise> { const db = this.db.sql() as unknown as Kysely; const excludedIds = this.db.trash.cache.notes; - const { content, title, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68056d730..2d27f43f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,7 @@ export { type DatabaseUpdatedEvent } from "./database/index.js"; export { FilteredSelector } from "./database/sql-collection.js"; export { getUpcomingReminder, + getUpcomingReminderTime, formatReminderTime, isReminderToday, isReminderActive diff --git a/packages/editor-mobile/src/hooks/useEditorController.ts b/packages/editor-mobile/src/hooks/useEditorController.ts index 169b5d6fc..e5b9a2ceb 100644 --- a/packages/editor-mobile/src/hooks/useEditorController.ts +++ b/packages/editor-mobile/src/hooks/useEditorController.ts @@ -153,18 +153,17 @@ export function useEditorController({ const titleChange = useCallback(async (title: string) => { if (!isReactNative()) return; const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const editedAt = Date.now(); + + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); const params = [ { title }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 1000 ]; @@ -186,12 +185,12 @@ export function useEditorController({ `Saving title failed, setting pending request ${pendingTitleIds.length}` ); if (params[2]) { - pendingSaveRequests.setTitle(params); + pendingSaveRequests.setTitle(params, editedAt); } const element = document.getElementById("editor-saving-failed-overlay"); if (element) { element.style.display = "flex"; - editors[tabRef.current.id]?.commands?.blur(); + editors[tabId]?.commands?.blur(); element.focus(); } }); @@ -216,27 +215,44 @@ export function useEditorController({ logger("info", "Edit skipped, tab is in loading state"); return; } + + if (ignoreEdit) { + logger("info", "Ignoring ignoreEdit update, a save is already pending"); + return; + } + const currentSessionId = globalThis.sessionId; - post( - EditorEvents.contentchange, - undefined, - tabRef.current.id, - tabRef.current.session?.noteId - ); + const tabId = tabRef.current.id; + const noteId = tabRef.current.session?.noteId; + post(EditorEvents.contentchange, undefined, tabId, noteId); if (!editor) return; if (typeof timers.current.change === "number") { clearTimeout(timers.current?.change); } timers.current.change = setTimeout(async () => { + if (tabRef.current.session?.noteId !== noteId) { + logger( + "info", + `Edit discarded, tab ${tabId} moved from note ${noteId} to ${tabRef.current.session?.noteId}` + ); + return; + } + if (editorControllers[tabId]?.loading) { + logger("info", "Edit discarded, tab is in loading state"); + return; + } + + const editedAt = Date.now(); htmlContentRef.current = editor.getHTML(); + const params = [ { html: htmlContentRef.current, ignoreEdit: ignoreEdit }, - tabRef.current.id, - tabRef.current.session?.noteId, + tabId, + noteId, currentSessionId, 5000 ]; @@ -262,7 +278,7 @@ export function useEditorController({ }` ); if (params[2]) { - pendingSaveRequests.setContent(params); + pendingSaveRequests.setContent(params, editedAt); } const element = document.getElementById( @@ -275,7 +291,7 @@ export function useEditorController({ }); logger("info", "Editor saving content", params[1], params[2]); - }, 300); + }, 100); countWords(5000); }, diff --git a/packages/editor-mobile/src/utils/pending-saves.ts b/packages/editor-mobile/src/utils/pending-saves.ts index b81703aa1..71c7a9fb4 100644 --- a/packages/editor-mobile/src/utils/pending-saves.ts +++ b/packages/editor-mobile/src/utils/pending-saves.ts @@ -23,13 +23,14 @@ class PendingSaveRequests { static TITLES = "pendingTitles"; static CONTENT = "pendingContents"; - async setTitle(value: any) { + async setTitle(value: any, editedAt: number) { const pendingTitles = JSON.parse( this.get(PendingSaveRequests.TITLES) || "[]" ); (pendingTitles as any[]).push({ id: randId("title-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -45,13 +46,14 @@ class PendingSaveRequests { return pendingTitles; } - async setContent(value: any) { + async setContent(value: any, editedAt: number) { const pendingContents = JSON.parse( this.get(PendingSaveRequests.CONTENT) || "[]" ); (pendingContents as any[]).push({ id: randId("content-pending"), + editedAt, params: value }); return localStorage.setItem( @@ -118,7 +120,10 @@ class PendingSaveRequests { const pendingTitles = await this.getPendingTitles(); this.remove(PendingSaveRequests.TITLES); for (const pending of pendingTitles) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.title, ...pending.params); } }; @@ -127,7 +132,10 @@ class PendingSaveRequests { const pendingContents = await this.getPendingContent(); this.remove(PendingSaveRequests.CONTENT); for (const pending of pendingContents) { - if (pending.params[0]) pending.params[0].pendingChanges = true; + if (pending.params[0]) { + pending.params[0].pendingChanges = true; + pending.params[0].pendingChangesAt = pending.editedAt; + } await postAsyncWithTimeout(EditorEvents.content, ...pending.params); } };