Merge branch 'master' into beta

This commit is contained in:
Ammar Ahmed
2026-08-05 08:42:26 +05:00
62 changed files with 1336 additions and 469 deletions

View File

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

View File

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

View File

@@ -86,8 +86,9 @@
android:name=".NotePreviewWidget"
android:exported="false"
android:label="@string/note">
<intent-filter>"
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
</intent-filter>
<meta-data
@@ -95,6 +96,15 @@
android:resource="@xml/note_widget_info" />
</receiver>
<receiver
android:name=".WidgetTimeChangeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
<receiver
android:name=".ReminderWidgetProvider"
android:exported="false"
@@ -230,11 +240,6 @@
</intent-filter>
</service>
<service
android:name=".ReminderViewsService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -5,15 +5,11 @@ import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViews;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
public class NotePreviewConfigureActivity extends ReactActivity {
@@ -40,18 +36,32 @@ public class NotePreviewConfigureActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
Intent intent = getIntent();
Bundle extras = intent.getExtras();
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
}
activity = this;
readAppWidgetId(getIntent());
}
/**
* We launch as singleTask, so configuring a second widget while this screen is still alive
* arrives here rather than in onCreate(). Without this the activity would keep writing to
* whichever widget it happened to be opened for first.
*/
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
activity = this;
readAppWidgetId(intent);
}
private void readAppWidgetId(Intent intent) {
Bundle extras = intent != null ? intent.getExtras() : null;
int appWidgetId = extras != null
? extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
: AppWidgetManager.INVALID_APPWIDGET_ID;
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(Activity.RESULT_CANCELED, resultValue);
activity = this;
}
public static void saveAndFinish(Context context) {

View File

@@ -1,6 +1,5 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
@@ -8,51 +7,125 @@ 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.view.View;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.HashSet;
import java.util.Set;
public class NotePreviewWidget extends AppWidgetProvider {
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
if (data.isEmpty()) {
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
Note note = WidgetUtils.parseNote(data);
if (note == null) {
// Either the widget was never configured, or we lost the note it pointed at (ids
// reassigned, data cleared). Point it back at the picker rather than leaving the user
// with an inert widget they can only fix by deleting and re-adding it.
views.setTextViewText(R.id.widget_title, context.getString(R.string.widget_note_unconfigured_title));
views.setTextViewText(R.id.widget_body, context.getString(R.string.widget_note_unconfigured_body));
views.setOnClickPendingIntent(R.id.open_note, getConfigurePendingIntent(context, appWidgetId));
appWidgetManager.updateAppWidget(appWidgetId, views);
return;
}
Gson gson = new Gson();
Note note = gson.fromJson(data, Note.class);
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
views.setTextViewText(R.id.widget_title, note.getTitle());
views.setTextViewText(R.id.widget_body, note.getHeadline());
// Once the user shrinks the widget down to a single row there is no room for the preview
// text, and a clipped half-line of it looks like a rendering glitch.
views.setViewVisibility(R.id.widget_body,
hasRoomForBody(appWidgetManager, appWidgetId) ? View.VISIBLE : View.GONE);
Intent intent = new Intent(context, MainActivity.class);
intent.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
/**
* Reopens the configure screen for this widget. The launcher's own "reconfigure" gesture is
* hard to discover and not offered by every launcher, so an unconfigured widget needs its own
* way back in.
*/
private static PendingIntent getConfigurePendingIntent(Context context, int appWidgetId) {
Intent intent = new Intent(context, NotePreviewConfigureActivity.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
// PendingIntent equality ignores extras, so the widget id has to be the request code for
// each widget to get its own.
return PendingIntent.getActivity(context, appWidgetId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
WidgetUtils.getActivityOptionsBundle());
}
/**
* Height below which the note preview text is dropped, leaving just the title.
*/
private static final int MIN_HEIGHT_FOR_BODY_DP = 70;
private static boolean hasRoomForBody(AppWidgetManager appWidgetManager, int appWidgetId) {
Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
if (options == null) return true;
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
// Not reported yet (the widget has just been placed): assume there is room.
return minHeight <= 0 || minHeight >= 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<String> 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

View File

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

View File

@@ -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<String, ?> map = pref.getAll();
WritableArray arr = Arguments.createArray();
for(Map.Entry<String,?> 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<String, ?> map = pref.getAll();
boolean found = false;
for(Map.Entry<String,?> 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<String, ?> map = pref.getAll();
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = pref.edit();
ArrayList<String> ids = new ArrayList<>();
for(Map.Entry<String,?> entry : map.entrySet()) {
String value = (String) entry.getValue();
if (value.contains(noteId)) {
edit.putString(entry.getKey(), data);
ids.add(entry.getKey());
}
List<Integer> 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<Integer, Note> 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);
}
}

View File

@@ -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<Reminder> reminders;
public ReminderRemoteViewsFactory(Context context, Intent intent) {
this.context = context;
}
@Override
public void onCreate() {
// Initialize reminders list
reminders = new ArrayList<Reminder>();
}
@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<List<Reminder>>(){}.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;
}
}

View File

@@ -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<Reminder> 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);
}
}
/**
* 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();
}
}

View File

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

View File

@@ -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<String> 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<Integer, Note> getWidgetNotes(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
Map<Integer, Note> notes = new LinkedHashMap<>();
for (Map.Entry<String, ?> 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<Reminder> getWidgetReminders(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
List<Reminder> stored = null;
try {
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
new TypeToken<List<Reminder>>() {}.getType());
} catch (Exception e) {
Log.e("Reminders", "Could not read the stored reminders list", e);
}
List<Reminder> 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();
}
}

View File

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

View File

@@ -2,6 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/background"/>
<stroke android:width="0dp" android:color="#B1BCBE" />
<corners android:radius="10dp"/>
<corners android:radius="@dimen/widget_background_radius"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@@ -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">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -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">
<LinearLayout
android:layout_width="match_parent"
@@ -25,7 +24,7 @@
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold"
android:text="Widget unconfigured" />
android:text="@string/widget_note_unconfigured_title" />
<TextView
android:id="@+id/widget_body"
android:layout_width="wrap_content"
@@ -34,7 +33,7 @@
android:layout_marginLeft="8dp"
android:textColor="@color/text"
android:textSize="14sp"
android:text="Configure this widget to show a note here." />
android:text="@string/widget_note_unconfigured_body" />
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors note_widget.xml, but with sample content, since the
real layout has nothing to show until the widget has been configured.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background="@drawable/layout_bg"
android:elevation="5dp"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_body"
android:textColor="@color/text"
android:textSize="14sp" />
</LinearLayout>
</RelativeLayout>

View File

@@ -1,8 +0,0 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="No upcoming reminders"
android:textSize="16sp"
android:textColor="@android:color/darker_gray"
android:gravity="center" />

View File

@@ -12,22 +12,23 @@
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:layout_gravity="center"
android:paddingTop="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:textSize="16sp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="@color/text"
android:text="Upcoming Reminders"/>
android:text="Reminders"/>
<ImageButton
android:layout_width="35dp"
android:layout_width="25dp"
android:id="@+id/add_button"
android:layout_height="35dp"
android:layout_height="25dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote" />
</RelativeLayout>
@@ -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"/>
</LinearLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors widget_reminders.xml, but with the list replaced by
sample rows, since an adapter-backed list renders empty in the picker.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/layout_bg"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="12dp"
android:paddingTop="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:text="Reminders"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<ImageButton
android:layout_width="35dp"
android:layout_height="35dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote"
android:contentDescription="@string/add_widget" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="8dp"
android:background="@color/border" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/widget_preview_reminder_time"
android:textColor="@color/text"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title_alt"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_time_alt"
android:textColor="@color/text"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#1f1f1f</color>
<color name="background">#1D1D1D</color>
<color name="border">#2E2E2E</color>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 onwards the launcher tells us what radius widgets should use, so ours line up
with every other widget on the home screen instead of being a fixed 10dp. -->
<dimen name="widget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
</resources>

View File

@@ -1,6 +0,0 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetBackgroundColor" format="color" />
<attr name="appWidgetTextColor" format="color" />
</declare-styleable>
</resources>

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#FFFFFF</color>
<color name="background">#DCEDEDED</color>
<color name="border">#BFBFBF</color>

View File

@@ -7,4 +7,8 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
<!-- Overridden in values-v31 with the platform's own widget radius, so our widgets match the
rest of the home screen. This is the fallback for older versions. -->
<dimen name="widget_background_radius">10dp</dimen>
</resources>

View File

@@ -4,9 +4,28 @@
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="take_a_quick_note">Take a quick note.</string>
<string name="reminders">Quick overview of upcoming reminders</string>
<string name="reminders">Quick overview of reminders</string>
<string name="reminders_title">Reminders</string>
<string name="note">Note</string>
<string name="note_description">Add a note to home screen</string>
<string name="quick_note">Quick note</string>
<string name="widget_reminders_empty">Tap + to add a reminder</string>
<!-- Sample content, only ever shown in the widget picker's preview. -->
<string name="widget_preview_note_title">Meeting notes</string>
<string name="widget_preview_note_body">Discuss the roadmap and agree on timelines.</string>
<string name="widget_preview_reminder_title">Take a walk</string>
<string name="widget_preview_reminder_time">Upcoming: Today, 5:00 PM</string>
<string name="widget_preview_reminder_title_alt">Call the dentist</string>
<string name="widget_preview_reminder_time_alt">Upcoming: Tomorrow, 9:00 AM</string>
<string name="widget_note_unconfigured_title">Tap to choose a note</string>
<string name="widget_note_unconfigured_body">Pick the note you want shown here.</string>
<string name="reminder_ongoing">Ongoing</string>
<string name="reminder_snoozed_until">Snoozed until %1$s</string>
<string name="reminder_today">Today, %1$s</string>
<string name="reminder_tomorrow">Tomorrow, %1$s</string>
<string name="reminder_yesterday">Yesterday, %1$s</string>
<string name="reminder_upcoming">Upcoming: %1$s</string>
<string name="reminder_last">Last: %1$s</string>
</resources>

View File

@@ -1,7 +0,0 @@
<resources>
<style name="ThemeOverlay.Notesnook.AppWidgetContainer" parent="">
<item name="appWidgetBackgroundColor">@color/light_blue_600</item>
<item name="appWidgetTextColor">@color/light_blue_50</item>
</style>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
Keep this in sync with res/xml/note_widget_info.xml. It exists only to add not_keyguard: from
Android 16 QPR1 widgets are lock screen eligible by default, and this one renders the note's
title and preview text, which should not be readable on a locked device.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/note_widget"
android:initialLayout="@layout/note_widget"
android:configure="com.streetwriters.notesnook.NotePreviewConfigureActivity"
android:widgetFeatures="reconfigurable"
android:minResizeWidth="100dp"
android:minResizeHeight="50dp"
android:minWidth="400dp"
android:description="@string/note_description"
android:minHeight="50dp"
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|not_keyguard"/>

View File

@@ -0,0 +1,15 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen|not_keyguard"
/>

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="400dp"
android:minHeight="100dp"
android:minResizeWidth="400dp"
android:minResizeHeight="50dp"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:updatePeriodMillis="1024"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
/>

View File

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

View File

@@ -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 <Element {...props} />;
};
};
export default withStartupBoundry(withTheme(withErrorBoundry(App, "App")));

View File

@@ -143,9 +143,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}, [hide, show]);
const onNegativePress = async () => {
if (dialogInfo?.onClose) {
await dialogInfo.onClose();
}
hide();
};

View File

@@ -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<TabsRef, TabProps>(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({

View File

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

View File

@@ -17,54 +17,67 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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, {});
}
}
}

View File

@@ -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) => {

View File

@@ -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<NodeJS.Timeout>(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 (
<NavigationContainer onStateChange={onStateChange} ref={rootNavigatorRef}>
<RootStack.Navigator
screenOptions={{
headerShown: false
}}
initialRouteName={introCompleted ? "FluidPanelsView" : "Welcome"}
initialRouteName={initialRouteName}
>
<RootStack.Screen
name="Welcome"
@@ -347,6 +414,12 @@ export const RootNavigation = () => {
require("../navigation/fluid-panels-view").default;
return FluidPanelsView;
}}
initialParams={{
initialPage:
initialShortcut?.type === "notesnook.action.newnote"
? "editor"
: undefined
}}
/>
<RootStack.Screen

View File

@@ -20,8 +20,9 @@ import { Note, Reminder } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import dayjs from "dayjs";
import React, { useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
BackHandler,
KeyboardAvoidingView,
Platform,
ScrollView,
@@ -63,6 +64,7 @@ import FormInput, {
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
import { presentDialog } from "../../components/dialog/functions";
const ReminderModes =
Platform.OS === "ios"
@@ -94,7 +96,7 @@ const ReminderNotificationModes = {
};
export default function AddReminder(props: NavigationProps<"AddReminder">) {
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<Reminder["mode"]>(
@@ -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<string>();
const [selectDayError, setSelectDayError] = useState<string>();
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">) {
<Header
title={reminder ? strings.editReminder() : strings.newReminder()}
canGoBack
onLeftMenuButtonPress={handleBackNavigation}
rightButton={{
name: "check",
onPress: saveReminder
}}
/>
<Dialog context="local" />
<ScrollView
style={{
marginBottom: DDS.isTab ? 25 : undefined,

View File

@@ -90,9 +90,10 @@ export type SavePayload = {
data?: string;
type?: "tiptap";
sessionHistoryId?: number;
ignoreEdit: boolean;
tabId: string;
pendingChanges?: boolean;
sourceNoteId?: string;
pendingChangesAt?: number;
};
export type AppState = {

View File

@@ -149,7 +149,7 @@ const showActionsheet = async () => {
}
};
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:

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -17,8 +17,12 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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();
}

View File

@@ -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<Item>;
};
Search:
| {
placeholder: string;
type: "note";
title: string;
route: RouteName;
items: FilteredSelector<Note>;
}
| {
placeholder: string;
type: Exclude<ItemType, "note">;
title: string;
route: RouteName;
items?: FilteredSelector<Item>;
};
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;

View File

@@ -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<SettingStore>((set, get) => ({
});
},
inboxEnabled: false,
setInboxEnabled: (inboxEnabled) => set({ inboxEnabled })
setInboxEnabled: (inboxEnabled) => set({ inboxEnabled }),
pendingShortcut: null
}));

View File

@@ -45,6 +45,7 @@ interface NotesnookModuleInterface {
hasWidgetNote: (noteId: string) => Promise<boolean>;
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),

View File

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

View File

@@ -4092,6 +4092,6 @@ SPEC CHECKSUMS:
toolbar-android: c426ed5bd3dcccfed20fd79533efc0d1ae0ef018
Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb
PODFILE CHECKSUM: 3fe13efa8356dcc061862bfa9f453dcd12ede70a
PODFILE CHECKSUM: 30b2045c0f4fc91402a43a9e2a872af803f2d6c3
COCOAPODS: 1.16.2

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
# Xcode >= 26.2 rejects fmt's compile-time format-string check with
#
# call to consteval function 'fmt::fstring<...>::fstring<char[N]>' 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

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.4.7",
"version": "3.4.8",
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {

View File

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

View File

@@ -114,7 +114,6 @@ export default class Lookup {
): Promise<VirtualizedGrouping<HighlightedResult>> {
const db = this.db.sql() as unknown as Kysely<RawDatabaseSchema>;
const excludedIds = this.db.trash.cache.notes;
const {
content,
title,

View File

@@ -30,6 +30,7 @@ export { type DatabaseUpdatedEvent } from "./database/index.js";
export { FilteredSelector } from "./database/sql-collection.js";
export {
getUpcomingReminder,
getUpcomingReminderTime,
formatReminderTime,
isReminderToday,
isReminderActive

View File

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

View File

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