fix(notifications): fix iOS notification scheduling and enable UI for native platforms

- Fix permission request to use correct iOS method (requestNotificationPermissions)
- Add robust handling for varying permission result formats
- Fix cancelDailyReminder to pass object parameter matching Swift plugin expectation
- Add notification cancellation before rescheduling to prevent duplicates
- Add verification after scheduling to ensure notification was actually scheduled
- Fix getStatus to handle both array and object response formats
- Enable notifications section in AccountView for native platforms (iOS/Android)
- Add edit button to allow users to modify existing notification time and message
- Add editReminderNotification method with form pre-population
- Add parseTimeTo24Hour helper for time format conversion

Fixes issues where:
- Notifications were stored but not actually scheduled with UNUserNotificationCenter
- cancelDailyReminder failed due to parameter type mismatch
- Notification time updates didn't properly cancel old notifications
- Users couldn't easily edit existing notification settings

The notification section is now visible on native platforms and includes an edit
button that opens the notification dialog with current values pre-populated.
This commit is contained in:
Jose Olarte III
2026-01-26 21:35:20 +08:00
parent 5a4ab84bfe
commit 31dfeb0988
2 changed files with 245 additions and 21 deletions

View File

@@ -12,6 +12,14 @@
*/
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
/**
* Extended type for DailyNotification that includes the actual Swift implementation
* signature for cancelDailyReminder (which expects an object, not a string)
*/
interface DailyNotificationWithObjectCancel {
cancelDailyReminder(options: { reminderId: string }): Promise<void>;
}
import type {
NotificationServiceInterface,
DailyNotificationOptions,
@@ -50,14 +58,26 @@ export class NativeNotificationService implements NotificationServiceInterface {
try {
logger.debug("[NativeNotificationService] Requesting permissions");
const result = await DailyNotification.requestPermissions();
// Use requestNotificationPermissions() which is the method exposed by iOS
// For Android, this should also work via the plugin bridge
const result = await DailyNotification.requestNotificationPermissions();
logger.debug("[NativeNotificationService] Permission result:", {
notificationsEnabled: result.notificationsEnabled,
allPermissionsGranted: result.allPermissionsGranted,
});
logger.debug("[NativeNotificationService] Permission result:", result);
return result.allPermissionsGranted;
// The result may be PermissionStatus (with granted boolean) or PermissionStatusResult
// Handle both formats for compatibility
if ("granted" in result && typeof result.granted === "boolean") {
return result.granted;
}
if (
"allPermissionsGranted" in result &&
typeof result.allPermissionsGranted === "boolean"
) {
return result.allPermissionsGranted;
}
// Fallback: check status after requesting
const status = await DailyNotification.checkPermissionStatus();
return status.allPermissionsGranted;
} catch (error) {
logger.error(
"[NativeNotificationService] Permission request failed:",
@@ -110,9 +130,31 @@ export class NativeNotificationService implements NotificationServiceInterface {
{
time: options.time,
title: options.title,
body: options.body,
},
);
// Cancel any existing notification with the same ID before scheduling a new one
// This ensures the old notification is removed from iOS notification center
try {
logger.debug(
"[NativeNotificationService] Canceling existing notification before rescheduling",
);
// The Swift plugin expects an object with reminderId property
// Even though TypeScript definition says string, we need to pass an object
await (
DailyNotification as unknown as DailyNotificationWithObjectCancel
).cancelDailyReminder({
reminderId: this.reminderId,
});
} catch (cancelError) {
// Ignore errors if notification doesn't exist - that's fine
logger.debug(
"[NativeNotificationService] No existing notification to cancel (or cancel failed):",
cancelError,
);
}
await DailyNotification.scheduleDailyReminder({
id: this.reminderId,
title: options.title,
@@ -124,10 +166,57 @@ export class NativeNotificationService implements NotificationServiceInterface {
priority: options.priority || "normal",
});
logger.info(
"[NativeNotificationService] Daily notification scheduled successfully",
// Verify the notification was actually scheduled
logger.debug(
"[NativeNotificationService] Verifying notification was scheduled",
);
return true;
const remindersResult = await DailyNotification.getScheduledReminders();
// Handle both array and object with reminders property
const reminders = Array.isArray(remindersResult)
? remindersResult
: (remindersResult as { reminders: typeof remindersResult })
.reminders || [];
const scheduledReminder = reminders.find((r) => r.id === this.reminderId);
if (scheduledReminder && scheduledReminder.isScheduled) {
// Verify the time matches what we scheduled
if (scheduledReminder.time !== options.time) {
logger.error(
"[NativeNotificationService] Notification time mismatch!",
{
scheduled: scheduledReminder.time,
requested: options.time,
reminderId: this.reminderId,
},
);
return false;
}
logger.info(
"[NativeNotificationService] Daily notification scheduled successfully:",
{
id: scheduledReminder.id,
time: scheduledReminder.time,
requestedTime: options.time,
nextTriggerTime: scheduledReminder.nextTriggerTime,
},
);
return true;
} else {
logger.warn(
"[NativeNotificationService] Notification was not found in scheduled reminders after scheduling",
{
reminderId: this.reminderId,
requestedTime: options.time,
allReminders: reminders.map((r) => ({
id: r.id,
time: r.time,
isScheduled: r.isScheduled,
})),
},
);
return false;
}
} catch (error) {
logger.error("[NativeNotificationService] Schedule failed:", error);
return false;
@@ -140,7 +229,13 @@ export class NativeNotificationService implements NotificationServiceInterface {
async cancelDailyNotification(): Promise<void> {
try {
logger.info("[NativeNotificationService] Cancelling daily notification");
await DailyNotification.cancelDailyReminder(this.reminderId);
// The Swift plugin expects an object with reminderId property
// Even though TypeScript definition says string, we need to pass an object
await (
DailyNotification as unknown as DailyNotificationWithObjectCancel
).cancelDailyReminder({
reminderId: this.reminderId,
});
logger.info(
"[NativeNotificationService] Daily notification cancelled successfully",
);
@@ -155,10 +250,13 @@ export class NativeNotificationService implements NotificationServiceInterface {
*/
async getStatus(): Promise<NotificationStatus> {
try {
const reminders = await DailyNotification.getScheduledReminders();
const reminder = reminders.reminders.find(
(r) => r.id === this.reminderId,
);
const remindersResult = await DailyNotification.getScheduledReminders();
// Handle both array and object with reminders property
const reminders = Array.isArray(remindersResult)
? remindersResult
: (remindersResult as { reminders: typeof remindersResult })
.reminders || [];
const reminder = reminders.find((r) => r.id === this.reminderId);
if (reminder) {
logger.debug("[NativeNotificationService] Found active reminder:", {

View File

@@ -76,10 +76,9 @@
/>
<!-- Notifications -->
<!-- Currently disabled because it doesn't work, even on Chrome.
If restored, make sure it works or doesn't show on mobile/electron. -->
<!-- Enabled for native platforms (iOS/Android) -->
<section
v-if="false"
v-if="isNativePlatform"
id="sectionNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
aria-labelledby="notificationsHeading"
@@ -117,11 +116,23 @@
></div>
</div>
</div>
<div v-if="notifyingReminder" class="w-full flex justify-between">
<span class="ml-8 mr-8">Message: "{{ notifyingReminderMessage }}"</span>
<span>{{ notifyingReminderTime.replace(" ", "&nbsp;") }}</span>
<div v-if="notifyingReminder" class="w-full">
<div class="flex justify-between mb-2">
<span class="ml-8 mr-8"
>Message: "{{ notifyingReminderMessage }}"</span
>
<span>{{ notifyingReminderTime.replace(" ", "&nbsp;") }}</span>
</div>
<div class="mt-2 text-center">
<button
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="editReminderNotification"
>
Edit Notification Details
</button>
</div>
</div>
<div class="mt-2 flex items-center justify-between">
<div v-if="false" class="mt-2 flex items-center justify-between">
<!-- label -->
<div>
New Activity Notification
@@ -785,6 +796,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
import { NotificationService } from "@/services/notifications";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -797,6 +809,17 @@ interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
interface PushNotificationPermissionRef {
open: (
title: string,
callback: (success: boolean, timeText: string, message?: string) => void,
) => void;
hourInput?: string;
minuteInput?: string;
hourAm?: boolean;
messageInput?: string;
}
@Component({
components: {
EntityIcon,
@@ -1211,6 +1234,109 @@ export default class AccountViewView extends Vue {
}
}
/**
* Edit existing reminder notification
* Opens the notification dialog to allow editing time and message
*/
async editReminderNotification(): Promise<void> {
const dialog = this.$refs
.pushNotificationPermission as PushNotificationPermission;
// Open the dialog - it will use the same callback pattern as showReminderNotificationChoice
dialog.open(
DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => {
if (success) {
// Cancel the old notification before scheduling the new one
const service = NotificationService.getInstance();
await service.cancelDailyNotification();
// Schedule the updated notification
const time24h = this.parseTimeTo24Hour(timeText);
const title = "Daily Reminder";
const body =
message ||
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
const scheduleSuccess = await service.scheduleDailyNotification({
time: time24h,
title,
body,
priority: "normal",
});
if (scheduleSuccess) {
await this.$saveSettings({
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText;
}
}
},
);
// Pre-populate the form with current values after dialog opens
// Use setTimeout to ensure the dialog is fully rendered
setTimeout(() => {
// Parse the current time string (e.g., "5:22 PM")
const timeMatch = this.notifyingReminderTime.match(
/(\d+):(\d+)\s*(AM|PM)/i,
);
if (timeMatch) {
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
// Convert to 12-hour format for the input
if (hour === 12) {
hour = 12;
} else if (hour > 12) {
hour = hour - 12;
}
// Set the component's properties directly
// Note: We need to access the component's internal properties
// This is a workaround but necessary to pre-populate
const dialogComponent =
dialog as unknown as PushNotificationPermissionRef;
if (dialogComponent) {
dialogComponent.hourInput = hour.toString();
dialogComponent.minuteInput = minute;
dialogComponent.hourAm = isAm;
dialogComponent.messageInput =
this.notifyingReminderMessage ||
"Click to share some gratitude with the world -- even if they're unnamed.";
}
}
}, 150);
}
/**
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
*/
private parseTimeTo24Hour(timeStr: string): string {
const timeMatch = timeStr.match(/(\d+):(\d+)\s*(AM|PM)/i);
if (!timeMatch) {
return "09:00"; // Default fallback
}
let hour = parseInt(timeMatch[1], 10);
const minute = timeMatch[2];
const isAm = timeMatch[3].toUpperCase() === "AM";
// Convert to 24-hour format
if (isAm && hour === 12) {
hour = 0;
} else if (!isAm && hour !== 12) {
hour = hour + 12;
}
return `${hour.toString().padStart(2, "0")}:${minute}`;
}
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
const newSetting = !this.hideRegisterPromptOnNewContact;
await this.$saveSettings({