Compare commits
9 Commits
dialog-sty
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eecef6d32 | ||
|
|
9122974fc9 | ||
|
|
fbacf740de | ||
| 810bfa1675 | |||
|
|
cf34952c1f | ||
|
|
7e5c16446f | ||
|
|
d62178bca5 | ||
|
|
1f716100fd | ||
|
|
0493f4f061 |
321
.cursor/rules/component-creation-ideals.mdc
Normal file
321
.cursor/rules/component-creation-ideals.mdc
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
version: "2.0.0"
|
||||||
|
lastUpdated: "2025-08-15"
|
||||||
|
priority: "critical"
|
||||||
|
---
|
||||||
|
# Component Creation Ideals — TimeSafari Architecture (Directive MDC, v2)
|
||||||
|
|
||||||
|
> **Agent role**: Apply these rules when creating, refactoring, or reviewing Vue components (Vue 3 with **vue-facing-decorator**). Prioritize **self-contained** components that hydrate from and persist to **PlatformServiceMixin** settings. Minimize parent–child coupling and prop drilling. Prefer **concision** where a separate component would add more API surface than value.
|
||||||
|
|
||||||
|
## 📚 Cross-References
|
||||||
|
|
||||||
|
- **Migration Example**: See `src/components/NotificationSection.vue` for successful refactor implementation
|
||||||
|
- **Parent Component**: See `src/views/AccountViewView.vue` for before/after comparison
|
||||||
|
- **Settings Infrastructure**: See `src/utils/PlatformServiceMixin.ts` for available methods
|
||||||
|
- **Related Rules**: See `.cursor/rules/` for other architectural guidelines
|
||||||
|
|
||||||
|
## Golden Rules (Enforce)
|
||||||
|
|
||||||
|
### Priority Levels
|
||||||
|
- 🔴 **CRITICAL**: Must be followed - breaking these creates architectural problems
|
||||||
|
- 🟡 **HIGH**: Strongly recommended - important for maintainability
|
||||||
|
- 🟢 **MEDIUM**: Good practice - improves code quality
|
||||||
|
|
||||||
|
1. **Self-Contained Components** 🔴 **CRITICAL**
|
||||||
|
- Do **not** require parent props for internal state or behavior.
|
||||||
|
- Hydrate on `mounted()` via `this.$accountSettings()`; persist with `this.$saveSettings()`.
|
||||||
|
- Encapsulate business logic inside the component; avoid delegating core logic to the parent.
|
||||||
|
- Prefer **computed** getters for derived values; avoid stored duplicates of derived state.
|
||||||
|
|
||||||
|
2. **Settings-First Architecture** 🔴 **CRITICAL**
|
||||||
|
- Use `PlatformServiceMixin` for reading/writing settings. Do **not** introduce new state managers (Pinia, custom stores) unless the state is *truly global*.
|
||||||
|
- Prefer **fetch-on-mount** over passing values via props.
|
||||||
|
|
||||||
|
3. **Single Responsibility** 🟡 **HIGH**
|
||||||
|
- Each component owns **one clear purpose** (UI + related logic + settings persistence). Avoid splitting UI/logic across multiple components unless reusability clearly benefits.
|
||||||
|
|
||||||
|
4. **Internal State Lifecycle** 🟡 **HIGH**
|
||||||
|
- Pattern: **defaults → hydrate on mount → computed for derived → persist on change**.
|
||||||
|
- Handle hydration errors gracefully (keep safe defaults; surface actionable UI states as needed).
|
||||||
|
|
||||||
|
5. **Minimal Props** 🔴 **CRITICAL**
|
||||||
|
- Props are for **pure configuration** (labels, limits, feature flags). Do not pass data that can be loaded internally.
|
||||||
|
- **Never** pass props that mirror settings values (e.g., `isRegistered`, `notifying*`). Load those from settings.
|
||||||
|
|
||||||
|
6. **Communication & Events** 🟡 **HIGH**
|
||||||
|
- Children may emit events for *user interactions* (e.g., `submitted`, `closed`) but **not** to offload core logic to the parent.
|
||||||
|
- Do not emit events solely to persist settings; the child handles persistence.
|
||||||
|
|
||||||
|
7. **Testing** 🟢 **MEDIUM**
|
||||||
|
- Unit tests mount components in isolation; mock `this.$accountSettings`/`this.$saveSettings`.
|
||||||
|
- Verify hydration on mount, persistence on mutation, and graceful failure on settings errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concision-First Decision Framework
|
||||||
|
|
||||||
|
> **Goal:** Avoid unnecessary components when a concise script, composable, or helper suffices.
|
||||||
|
|
||||||
|
**Prefer NOT making a component when:**
|
||||||
|
- **One-off UI**: used in exactly one view, unlikely to repeat.
|
||||||
|
- **Small scope**: ~≤100–150 LOC, ≤3 reactive fields, ≤2 handlers.
|
||||||
|
- **Purely presentational** with trivial logic.
|
||||||
|
- **Local invariants**: behavior depends entirely on the view’s context.
|
||||||
|
- **Abstraction cost > benefit**: would create an anemic component with props mirroring parent state.
|
||||||
|
- **Better fit as code reuse**: logic works as a **composable/service/helper** without introducing new UI.
|
||||||
|
|
||||||
|
**Concise alternatives:**
|
||||||
|
- **Composable**: `useFeature()` encapsulates settings I/O and state.
|
||||||
|
- **Service/Module**: plain TS helpers for formatting/validation.
|
||||||
|
- **Directive**: tiny DOM behaviors that don’t need a lifecycle boundary.
|
||||||
|
|
||||||
|
**When to make a component (even without reuse yet):**
|
||||||
|
- **Isolation boundary**: async side effects, permission prompts, or recoverable error states.
|
||||||
|
- **Stateful widget**: internal settings persistence, media controls, complex a11y.
|
||||||
|
- **Slots/composition**: needs flexible children or layout.
|
||||||
|
- **Different change rate**: sub-tree churns independently of the parent.
|
||||||
|
- **Testability/ownership**: clear, ownable surface that’s easier to unit-test in isolation.
|
||||||
|
|
||||||
|
**Rule of Three (guardrail):**
|
||||||
|
- 1st time: inline or composable.
|
||||||
|
- 2nd time: consider shared abstraction.
|
||||||
|
- 3rd time: extract a component.
|
||||||
|
|
||||||
|
**PR language (use when choosing concision):**
|
||||||
|
- “One-off, ~80 LOC, no expected reuse. A component would add an API surface with no consumer. Keeping it local reduces cognitive load. If we see a second usage, promote to a composable; third usage, a component.”
|
||||||
|
- “Logic lives in `useX()` to keep the view concise without prop plumbing; settings stay via `PlatformServiceMixin`.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (Reject)
|
||||||
|
|
||||||
|
- Props that duplicate internal/derivable state: `:is-registered`, `:notifying-*`, etc.
|
||||||
|
- Child components that are **UI-only** while parents hold the business logic for that feature.
|
||||||
|
- Introducing Pinia/custom stores for per-component state.
|
||||||
|
- Emitting `update:*` events to push settings responsibilities to parents.
|
||||||
|
- Scattering a feature across multiple micro-components without a clear reuse reason.
|
||||||
|
- Using props for computed/derived values (e.g., `showAdvancedFeatures` as a prop).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### ✅ DO (Self-Contained, Settings-First)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Parent renders with no props -->
|
||||||
|
<NotificationSection />
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// NotificationSection.vue (vue-facing-decorator + PlatformServiceMixin)
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class NotificationSection extends Vue {
|
||||||
|
private notifyingNewActivity: boolean = false;
|
||||||
|
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
await this.hydrateFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateFromSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await this.$accountSettings();
|
||||||
|
this.notifyingNewActivity = !!s.notifyingNewActivityTime;
|
||||||
|
} catch (err) {
|
||||||
|
// Keep defaults; optionally surface a non-blocking UI notice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateNotifying(state: boolean): Promise<void> {
|
||||||
|
await this.$saveSettings({ notifyingNewActivityTime: state ? Date.now() : null });
|
||||||
|
this.notifyingNewActivity = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ DON'T (Prop-Driven Coupling)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Parent passes internal state down -->
|
||||||
|
<NotificationSection
|
||||||
|
:is-registered="isRegistered"
|
||||||
|
:notifying-new-activity="notifyingNewActivity"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Child receives props (anti-pattern)
|
||||||
|
export default class NotificationSection extends Vue {
|
||||||
|
isRegistered: boolean = false;
|
||||||
|
notifyingNewActivity: boolean = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Structure Template (Drop-In)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* ComponentName.vue — Purpose
|
||||||
|
* Owns: UI + logic + settings persistence (PlatformServiceMixin).
|
||||||
|
*/
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import type { Router } from "vue-router";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
// child components here
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin], // Settings access
|
||||||
|
})
|
||||||
|
export default class ComponentName extends Vue {
|
||||||
|
// Internal state
|
||||||
|
private myState: boolean = false;
|
||||||
|
private myData: string = "";
|
||||||
|
|
||||||
|
// Derived
|
||||||
|
private get isEnabled(): boolean {
|
||||||
|
return this.myState && this.hasPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
await this.hydrateFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateFromSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await this.$accountSettings();
|
||||||
|
this.myState = !!s.mySetting;
|
||||||
|
this.myData = s.myData ?? "";
|
||||||
|
} catch (err) {
|
||||||
|
// keep defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateState(v: boolean): Promise<void> {
|
||||||
|
await this.$saveSettings({ mySetting: v });
|
||||||
|
this.myState = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUserAction(): Promise<void> {
|
||||||
|
await this.updateState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Playbook (Props → Self-Contained)
|
||||||
|
|
||||||
|
> **Agent, when you see a component receiving `@Prop()` values that mirror settings or internal state, apply this playbook.**
|
||||||
|
|
||||||
|
1. **Detect Anti-Props**
|
||||||
|
- Flag props matching: `is*`, `has*`, `notifying*`, or mirroring settings keys.
|
||||||
|
- Search for parent usage of these props and events.
|
||||||
|
|
||||||
|
2. **Inline State**
|
||||||
|
- Remove anti-props and `@Prop()` declarations.
|
||||||
|
- Add private fields for state inside the child.
|
||||||
|
- Add `mounted()` hydration and persistence helpers via `PlatformServiceMixin`.
|
||||||
|
|
||||||
|
3. **Parent Simplification**
|
||||||
|
- Replace `<Child :foo="..." :bar="..." @update="..."/>` with `<Child />`.
|
||||||
|
- Delete now-unused local state, watchers, and computed values that existed only to feed the child.
|
||||||
|
|
||||||
|
4. **Events**
|
||||||
|
- Keep only user-interaction events (e.g., `submitted`). Remove persistence/logic events that the child now owns.
|
||||||
|
|
||||||
|
5. **Tests**
|
||||||
|
- Update unit tests to mount child in isolation; mock settings I/O.
|
||||||
|
- Verify: hydrate on mount, persist on change, safe fallback on error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When Exceptions Are Acceptable
|
||||||
|
|
||||||
|
- **Truly Global State** across distant components → use Pinia/service *sparingly*.
|
||||||
|
- **Reusable Form Inputs** → accept `value` prop and emit `input`/`update:modelValue`; keep validation/business logic internal.
|
||||||
|
- **Configuration-Only Props** for labels, visual variants, or limits — not for state that can be fetched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (Checklist)
|
||||||
|
|
||||||
|
- [ ] No props required for internal state or settings-backed values.
|
||||||
|
- [ ] Uses `PlatformServiceMixin` for all settings I/O.
|
||||||
|
- [ ] Hydrates on `mounted()`, persists on mutations.
|
||||||
|
- [ ] Single-responsibility: UI + logic + persistence together.
|
||||||
|
- [ ] Computed getters for derived state.
|
||||||
|
- [ ] Unit tests mock settings and cover hydrate/persist/failure paths.
|
||||||
|
- [ ] Parent components contain no leftover `notifying-*` or similar prop wiring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Snippets
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Hydration on mount
|
||||||
|
test("hydrates from settings on mount", async () => {
|
||||||
|
const wrap = mount(MyComponent);
|
||||||
|
await wrap.vm.$nextTick();
|
||||||
|
expect((wrap.vm as any).myState).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Mock settings methods
|
||||||
|
jest.spyOn(wrapper.vm as any, "$accountSettings").mockResolvedValue({ mySetting: true });
|
||||||
|
jest.spyOn(wrapper.vm as any, "$saveSettings").mockResolvedValue(void 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Graceful failure
|
||||||
|
test("handles settings load failure", async () => {
|
||||||
|
jest.spyOn(wrapper.vm as any, "$accountSettings").mockRejectedValue(new Error("DB Error"));
|
||||||
|
const wrap = mount(MyComponent);
|
||||||
|
await wrap.vm.$nextTick();
|
||||||
|
expect((wrap.vm as any).myState).toBe(false); // default
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rationale (Short)
|
||||||
|
|
||||||
|
- **Concision first where appropriate** → avoid unnecessary components and API surfaces.
|
||||||
|
- **Reduced coupling** → portability and reuse when boundaries are justified.
|
||||||
|
- **Maintainability** → changes localized to the owning component.
|
||||||
|
- **Consistency** → one canonical path for settings-backed features.
|
||||||
|
|
||||||
|
**Rule of thumb:** _Can this feature operate independently, and does a component materially improve isolation or testability?_ If not, keep it concise (inline or composable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Version History
|
||||||
|
|
||||||
|
### v2.0.0 (2025-08-15)
|
||||||
|
- **Major Enhancement**: Added Concision-First Decision Framework
|
||||||
|
- **New**: Rule of Three guardrail for component extraction
|
||||||
|
- **New**: PR language guidance for code reviews
|
||||||
|
- **Enhanced**: Agent role includes concision preference
|
||||||
|
- **Refined**: Anti-patterns and examples updated
|
||||||
|
|
||||||
|
### v1.0.0 (2025-08-15)
|
||||||
|
- Initial creation based on successful NotificationSection refactor
|
||||||
|
- Established core architectural principles for self-contained components
|
||||||
|
- Added comprehensive migration playbook and testing guidelines
|
||||||
|
- Included practical examples and anti-pattern detection
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
- Additional migration patterns for complex components
|
||||||
|
- Integration with automated refactoring tools
|
||||||
|
- Performance benchmarking guidelines
|
||||||
|
- Advanced testing strategies for complex state management
|
||||||
@@ -14,12 +14,4 @@
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
background-color: #FFF !important;
|
background-color: #FFF !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-overlay {
|
|
||||||
@apply z-50 fixed inset-0 bg-black/50 flex justify-center items-center p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
@apply bg-white p-4 rounded-lg w-full max-w-lg;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- similar to UserNameDialog -->
|
<!-- similar to UserNameDialog -->
|
||||||
<template>
|
<template>
|
||||||
<div v-if="visible" class="dialog-overlay">
|
<div v-if="visible" :class="overlayClasses">
|
||||||
<div class="dialog">
|
<div :class="dialogClasses">
|
||||||
<h1 :class="titleClasses">{{ title }}</h1>
|
<h1 :class="titleClasses">{{ title }}</h1>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
Note that their name is only stored on this device.
|
Note that their name is only stored on this device.
|
||||||
@@ -61,6 +61,20 @@ export default class ContactNameDialog extends Vue {
|
|||||||
title = "Contact Name";
|
title = "Contact Name";
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS classes for the modal overlay backdrop
|
||||||
|
*/
|
||||||
|
get overlayClasses(): string {
|
||||||
|
return "z-index-50 fixed top-0 left-0 right-0 bottom-0 bg-black/50 flex justify-center items-center p-6";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS classes for the modal dialog container
|
||||||
|
*/
|
||||||
|
get dialogClasses(): string {
|
||||||
|
return "bg-white p-4 rounded-lg w-full max-w-[500px]";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the dialog title
|
* CSS classes for the dialog title
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -212,8 +212,30 @@ export default class FeedFilters extends Vue {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#dialogFeedFilters.dialog-overlay {
|
#dialogFeedFilters.dialog-overlay {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -665,3 +665,27 @@ export default class GiftedDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -291,3 +291,27 @@ export default class GivenPrompts extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -931,6 +931,32 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add styles for diagnostic panel */
|
/* Add styles for diagnostic panel */
|
||||||
.diagnostic-panel {
|
.diagnostic-panel {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
|||||||
@@ -93,3 +93,27 @@ export default class InviteDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
194
src/components/NotificationSection.vue
Normal file
194
src/components/NotificationSection.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="isRegistered"
|
||||||
|
id="sectionNotifications"
|
||||||
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||||
|
aria-labelledby="notificationsHeading"
|
||||||
|
>
|
||||||
|
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
Reminder Notification
|
||||||
|
<button
|
||||||
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
|
aria-label="Learn more about reminder notifications"
|
||||||
|
@click.stop="showReminderNotificationInfo"
|
||||||
|
>
|
||||||
|
<font-awesome-icon icon="question-circle" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative ml-2 cursor-pointer"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="notifyingReminder"
|
||||||
|
aria-label="Toggle reminder notifications"
|
||||||
|
tabindex="0"
|
||||||
|
@click="showReminderNotificationChoice()"
|
||||||
|
>
|
||||||
|
<!-- input -->
|
||||||
|
<input v-model="notifyingReminder" type="checkbox" class="sr-only" />
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></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(" ", " ") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<!-- label -->
|
||||||
|
<div>
|
||||||
|
New Activity Notification
|
||||||
|
<font-awesome-icon
|
||||||
|
icon="question-circle"
|
||||||
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
|
@click.stop="showNewActivityNotificationInfo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div
|
||||||
|
class="relative ml-2 cursor-pointer"
|
||||||
|
@click="showNewActivityNotificationChoice()"
|
||||||
|
>
|
||||||
|
<!-- input -->
|
||||||
|
<input v-model="notifyingNewActivity" type="checkbox" class="sr-only" />
|
||||||
|
<!-- line -->
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<!-- dot -->
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifyingNewActivityTime" class="w-full text-right">
|
||||||
|
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||||
|
Troubleshoot your notifications…
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<PushNotificationPermission ref="pushNotificationPermission" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
|
import { Router } from "vue-router";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||||
|
import PushNotificationPermission from "./PushNotificationPermission.vue";
|
||||||
|
import { createNotifyHelpers } from "@/utils/notify";
|
||||||
|
import { NotificationIface } from "@/constants/app";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import {
|
||||||
|
createNotificationSettingsService,
|
||||||
|
type NotificationSettingsService,
|
||||||
|
} from "@/composables/useNotificationSettings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NotificationSection.vue - Notification UI component with service-based logic
|
||||||
|
*
|
||||||
|
* This component handles the UI presentation of notification functionality:
|
||||||
|
* - Reminder notifications with custom messages
|
||||||
|
* - New activity notifications
|
||||||
|
* - Notification permission management
|
||||||
|
* - Help and troubleshooting links
|
||||||
|
*
|
||||||
|
* Business logic is delegated to NotificationSettingsService for:
|
||||||
|
* - Settings hydration and persistence
|
||||||
|
* - Registration status checking
|
||||||
|
* - Notification permission management
|
||||||
|
*
|
||||||
|
* The component maintains its lifecycle boundary while keeping logic separate.
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @component NotificationSection
|
||||||
|
* @vue-facing-decorator
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
PushNotificationPermission,
|
||||||
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class NotificationSection extends Vue {
|
||||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
$router!: Router;
|
||||||
|
|
||||||
|
// Notification settings service - handles all business logic
|
||||||
|
private notificationService!: NotificationSettingsService;
|
||||||
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.notify = createNotifyHelpers(this.$notify);
|
||||||
|
this.notificationService = createNotificationSettingsService(
|
||||||
|
this,
|
||||||
|
this.notify,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to determine if user is registered
|
||||||
|
* Reads from the notification service
|
||||||
|
*/
|
||||||
|
private get isRegistered(): boolean {
|
||||||
|
return this.notificationService.isRegistered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize component state from persisted settings
|
||||||
|
* Called when component is mounted
|
||||||
|
*/
|
||||||
|
async mounted(): Promise<void> {
|
||||||
|
await this.notificationService.hydrateFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed properties for template access
|
||||||
|
private get notifyingNewActivity(): boolean {
|
||||||
|
return this.notificationService.notifyingNewActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get notifyingNewActivityTime(): string {
|
||||||
|
return this.notificationService.notifyingNewActivityTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get notifyingReminder(): boolean {
|
||||||
|
return this.notificationService.notifyingReminder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get notifyingReminderMessage(): string {
|
||||||
|
return this.notificationService.notifyingReminderMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get notifyingReminderTime(): string {
|
||||||
|
return this.notificationService.notifyingReminderTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async showNewActivityNotificationInfo(): Promise<void> {
|
||||||
|
await this.notificationService.showNewActivityNotificationInfo(
|
||||||
|
this.$router,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showNewActivityNotificationChoice(): Promise<void> {
|
||||||
|
await this.notificationService.showNewActivityNotificationChoice(
|
||||||
|
this.$refs.pushNotificationPermission as PushNotificationPermission,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showReminderNotificationInfo(): Promise<void> {
|
||||||
|
await this.notificationService.showReminderNotificationInfo(this.$router);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showReminderNotificationChoice(): Promise<void> {
|
||||||
|
await this.notificationService.showReminderNotificationChoice(
|
||||||
|
this.$refs.pushNotificationPermission as PushNotificationPermission,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -312,3 +312,28 @@ export default class OfferDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -307,3 +307,27 @@ export default class OnboardingDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 40;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -628,6 +628,34 @@ export default class PhotoDialog extends Vue {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Dialog overlay styling */
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 60;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog container styling */
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
/* Camera preview styling */
|
/* Camera preview styling */
|
||||||
.camera-preview {
|
.camera-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -134,3 +134,27 @@ export default class UserNameDialog extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
233
src/composables/useNotificationSettings.ts
Normal file
233
src/composables/useNotificationSettings.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* useNotificationSettings.ts - Notification settings and permissions service
|
||||||
|
*
|
||||||
|
* This service handles all notification-related business logic including:
|
||||||
|
* - Settings hydration and persistence
|
||||||
|
* - Registration status checking
|
||||||
|
* - Notification permission management
|
||||||
|
* - Settings state management
|
||||||
|
*
|
||||||
|
* Separates business logic from UI components while maintaining
|
||||||
|
* the lifecycle boundary and settings access patterns.
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @service NotificationSettingsService
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createNotifyHelpers } from "@/utils/notify";
|
||||||
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
|
import { DAILY_CHECK_TITLE, DIRECT_PUSH_TITLE } from "@/libs/util";
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for notification settings state
|
||||||
|
*/
|
||||||
|
export interface NotificationSettingsState {
|
||||||
|
isRegistered: boolean;
|
||||||
|
notifyingNewActivity: boolean;
|
||||||
|
notifyingNewActivityTime: string;
|
||||||
|
notifyingReminder: boolean;
|
||||||
|
notifyingReminderMessage: string;
|
||||||
|
notifyingReminderTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for notification settings actions
|
||||||
|
*/
|
||||||
|
export interface NotificationSettingsActions {
|
||||||
|
hydrateFromSettings: () => Promise<void>;
|
||||||
|
updateNewActivityNotification: (
|
||||||
|
enabled: boolean,
|
||||||
|
timeText?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
updateReminderNotification: (
|
||||||
|
enabled: boolean,
|
||||||
|
timeText?: string,
|
||||||
|
message?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
showNewActivityNotificationInfo: (router: any) => Promise<void>;
|
||||||
|
showReminderNotificationInfo: (router: any) => Promise<void>;
|
||||||
|
showNewActivityNotificationChoice: (
|
||||||
|
pushNotificationPermissionRef: any,
|
||||||
|
) => Promise<void>;
|
||||||
|
showReminderNotificationChoice: (
|
||||||
|
pushNotificationPermissionRef: any,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service class for managing notification settings and permissions
|
||||||
|
*
|
||||||
|
* @param platformService - PlatformServiceMixin instance for settings access
|
||||||
|
* @param notify - Notification helper functions
|
||||||
|
*/
|
||||||
|
export class NotificationSettingsService
|
||||||
|
implements NotificationSettingsState, NotificationSettingsActions
|
||||||
|
{
|
||||||
|
// State properties
|
||||||
|
public isRegistered: boolean = false;
|
||||||
|
public notifyingNewActivity: boolean = false;
|
||||||
|
public notifyingNewActivityTime: string = "";
|
||||||
|
public notifyingReminder: boolean = false;
|
||||||
|
public notifyingReminderMessage: string = "";
|
||||||
|
public notifyingReminderTime: string = "";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platformService: ComponentPublicInstance & {
|
||||||
|
$accountSettings: () => Promise<any>;
|
||||||
|
$saveSettings: (changes: any) => Promise<boolean>;
|
||||||
|
},
|
||||||
|
private notify: ReturnType<typeof createNotifyHelpers>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load notification settings from database and hydrate internal state
|
||||||
|
* Uses the existing settings mechanism for consistency
|
||||||
|
*/
|
||||||
|
public async hydrateFromSettings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const settings = await this.platformService.$accountSettings();
|
||||||
|
|
||||||
|
// Hydrate registration status
|
||||||
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
|
// Hydrate boolean flags from time presence
|
||||||
|
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
||||||
|
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
||||||
|
this.notifyingReminder = !!settings.notifyingReminderTime;
|
||||||
|
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
||||||
|
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hydrate notification settings:", error);
|
||||||
|
// Keep default values on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update new activity notification settings
|
||||||
|
*/
|
||||||
|
public async updateNewActivityNotification(
|
||||||
|
enabled: boolean,
|
||||||
|
timeText: string = "",
|
||||||
|
): Promise<void> {
|
||||||
|
await this.platformService.$saveSettings({
|
||||||
|
notifyingNewActivityTime: timeText,
|
||||||
|
});
|
||||||
|
this.notifyingNewActivity = enabled;
|
||||||
|
this.notifyingNewActivityTime = timeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update reminder notification settings
|
||||||
|
*/
|
||||||
|
public async updateReminderNotification(
|
||||||
|
enabled: boolean,
|
||||||
|
timeText: string = "",
|
||||||
|
message: string = "",
|
||||||
|
): Promise<void> {
|
||||||
|
await this.platformService.$saveSettings({
|
||||||
|
notifyingReminderMessage: message,
|
||||||
|
notifyingReminderTime: timeText,
|
||||||
|
});
|
||||||
|
this.notifyingReminder = enabled;
|
||||||
|
this.notifyingReminderMessage = message;
|
||||||
|
this.notifyingReminderTime = timeText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show new activity notification info dialog
|
||||||
|
*/
|
||||||
|
public async showNewActivityNotificationInfo(router: any): Promise<void> {
|
||||||
|
this.notify.confirm(
|
||||||
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
||||||
|
async () => {
|
||||||
|
await router.push({
|
||||||
|
name: "help-notification-types",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show reminder notification info dialog
|
||||||
|
*/
|
||||||
|
public async showReminderNotificationInfo(router: any): Promise<void> {
|
||||||
|
this.notify.confirm(
|
||||||
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
|
||||||
|
async () => {
|
||||||
|
await router.push({
|
||||||
|
name: "help-notification-types",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new activity notification choice (enable/disable)
|
||||||
|
*/
|
||||||
|
public async showNewActivityNotificationChoice(
|
||||||
|
pushNotificationPermissionRef: any,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.notifyingNewActivity) {
|
||||||
|
// Enable notification
|
||||||
|
pushNotificationPermissionRef.open(
|
||||||
|
DAILY_CHECK_TITLE,
|
||||||
|
async (success: boolean, timeText: string) => {
|
||||||
|
if (success) {
|
||||||
|
await this.updateNewActivityNotification(true, timeText);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Disable notification
|
||||||
|
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
||||||
|
if (success) {
|
||||||
|
await this.updateNewActivityNotification(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle reminder notification choice (enable/disable)
|
||||||
|
*/
|
||||||
|
public async showReminderNotificationChoice(
|
||||||
|
pushNotificationPermissionRef: any,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.notifyingReminder) {
|
||||||
|
// Enable notification
|
||||||
|
pushNotificationPermissionRef.open(
|
||||||
|
DIRECT_PUSH_TITLE,
|
||||||
|
async (success: boolean, timeText: string, message?: string) => {
|
||||||
|
if (success) {
|
||||||
|
await this.updateReminderNotification(true, timeText, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Disable notification
|
||||||
|
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
||||||
|
if (success) {
|
||||||
|
await this.updateReminderNotification(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a NotificationSettingsService instance
|
||||||
|
*
|
||||||
|
* @param platformService - PlatformServiceMixin instance
|
||||||
|
* @param notify - Notification helper functions
|
||||||
|
* @returns NotificationSettingsService instance
|
||||||
|
*/
|
||||||
|
export function createNotificationSettingsService(
|
||||||
|
platformService: ComponentPublicInstance & {
|
||||||
|
$accountSettings: () => Promise<any>;
|
||||||
|
$saveSettings: (changes: any) => Promise<boolean>;
|
||||||
|
},
|
||||||
|
notify: ReturnType<typeof createNotifyHelpers>,
|
||||||
|
): NotificationSettingsService {
|
||||||
|
return new NotificationSettingsService(platformService, notify);
|
||||||
|
}
|
||||||
@@ -60,91 +60,7 @@
|
|||||||
@share-info="onShareInfo"
|
@share-info="onShareInfo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<NotificationSection />
|
||||||
<!-- Currently disabled because it doesn't work, even on Chrome.
|
|
||||||
If restored, make sure it works or doesn't show on mobile/electron. -->
|
|
||||||
<section
|
|
||||||
v-if="false"
|
|
||||||
id="sectionNotifications"
|
|
||||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
|
||||||
aria-labelledby="notificationsHeading"
|
|
||||||
>
|
|
||||||
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
Reminder Notification
|
|
||||||
<button
|
|
||||||
class="text-slate-400 fa-fw cursor-pointer"
|
|
||||||
aria-label="Learn more about reminder notifications"
|
|
||||||
@click.stop="showReminderNotificationInfo"
|
|
||||||
>
|
|
||||||
<font-awesome
|
|
||||||
icon="question-circle"
|
|
||||||
aria-hidden="true"
|
|
||||||
></font-awesome>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="relative ml-2 cursor-pointer"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="notifyingReminder"
|
|
||||||
aria-label="Toggle reminder notifications"
|
|
||||||
tabindex="0"
|
|
||||||
@click="showReminderNotificationChoice()"
|
|
||||||
>
|
|
||||||
<!-- input -->
|
|
||||||
<input v-model="notifyingReminder" type="checkbox" class="sr-only" />
|
|
||||||
<!-- line -->
|
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
||||||
<!-- dot -->
|
|
||||||
<div
|
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
||||||
></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(" ", " ") }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 flex items-center justify-between">
|
|
||||||
<!-- label -->
|
|
||||||
<div>
|
|
||||||
New Activity Notification
|
|
||||||
<font-awesome
|
|
||||||
icon="question-circle"
|
|
||||||
class="text-slate-400 fa-fw cursor-pointer"
|
|
||||||
@click.stop="showNewActivityNotificationInfo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- toggle -->
|
|
||||||
<div
|
|
||||||
class="relative ml-2 cursor-pointer"
|
|
||||||
@click="showNewActivityNotificationChoice()"
|
|
||||||
>
|
|
||||||
<!-- input -->
|
|
||||||
<input
|
|
||||||
v-model="notifyingNewActivity"
|
|
||||||
type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
/>
|
|
||||||
<!-- line -->
|
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
||||||
<!-- dot -->
|
|
||||||
<div
|
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="notifyingNewActivityTime" class="w-full text-right">
|
|
||||||
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-center">
|
|
||||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
|
||||||
Troubleshoot your notifications…
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<PushNotificationPermission ref="pushNotificationPermission" />
|
|
||||||
|
|
||||||
<LocationSearchSection :search-box="searchBox" />
|
<LocationSearchSection :search-box="searchBox" />
|
||||||
|
|
||||||
@@ -765,12 +681,13 @@ import { Capacitor } from "@capacitor/core";
|
|||||||
|
|
||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||||
import DataExportSection from "../components/DataExportSection.vue";
|
import DataExportSection from "../components/DataExportSection.vue";
|
||||||
import IdentitySection from "@/components/IdentitySection.vue";
|
import IdentitySection from "@/components/IdentitySection.vue";
|
||||||
|
import NotificationSection from "@/components/NotificationSection.vue";
|
||||||
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
import RegistrationNotice from "@/components/RegistrationNotice.vue";
|
||||||
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
import LocationSearchSection from "@/components/LocationSearchSection.vue";
|
||||||
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
import UsageLimitsSection from "@/components/UsageLimitsSection.vue";
|
||||||
@@ -796,11 +713,7 @@ import {
|
|||||||
getHeaders,
|
getHeaders,
|
||||||
tokenExpiryTimeDescription,
|
tokenExpiryTimeDescription,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import {
|
import { retrieveAccountMetadata } from "../libs/util";
|
||||||
DAILY_CHECK_TITLE,
|
|
||||||
DIRECT_PUSH_TITLE,
|
|
||||||
retrieveAccountMetadata,
|
|
||||||
} from "../libs/util";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
@@ -829,7 +742,7 @@ interface UserNameDialogRef {
|
|||||||
LMap,
|
LMap,
|
||||||
LMarker,
|
LMarker,
|
||||||
LTileLayer,
|
LTileLayer,
|
||||||
PushNotificationPermission,
|
NotificationSection,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
UserNameDialog,
|
UserNameDialog,
|
||||||
@@ -880,12 +793,7 @@ export default class AccountViewView extends Vue {
|
|||||||
includeUserProfileLocation: boolean = false;
|
includeUserProfileLocation: boolean = false;
|
||||||
savingProfile: boolean = false;
|
savingProfile: boolean = false;
|
||||||
|
|
||||||
// Notification properties
|
// Push notification subscription (kept for service worker checks)
|
||||||
notifyingNewActivity: boolean = false;
|
|
||||||
notifyingNewActivityTime: string = "";
|
|
||||||
notifyingReminder: boolean = false;
|
|
||||||
notifyingReminderMessage: string = "";
|
|
||||||
notifyingReminderTime: string = "";
|
|
||||||
subscription: PushSubscription | null = null;
|
subscription: PushSubscription | null = null;
|
||||||
|
|
||||||
// UI state properties
|
// UI state properties
|
||||||
@@ -1003,10 +911,8 @@ export default class AccountViewView extends Vue {
|
|||||||
const registration = await navigator.serviceWorker?.ready;
|
const registration = await navigator.serviceWorker?.ready;
|
||||||
this.subscription = await registration.pushManager.getSubscription();
|
this.subscription = await registration.pushManager.getSubscription();
|
||||||
if (!this.subscription) {
|
if (!this.subscription) {
|
||||||
if (this.notifyingNewActivity || this.notifyingReminder) {
|
// Notification settings cleanup is now handled by the NotificationSection component
|
||||||
// the app thought there was a subscription but there isn't, so fix the settings
|
// which manages its own state lifecycle and persistence
|
||||||
this.turnOffNotifyingFlags();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.notify.warning(
|
this.notify.warning(
|
||||||
@@ -1046,11 +952,6 @@ export default class AccountViewView extends Vue {
|
|||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
this.isSearchAreasSet = !!settings.searchBoxes;
|
this.isSearchAreasSet = !!settings.searchBoxes;
|
||||||
this.searchBox = settings.searchBoxes?.[0] || null;
|
this.searchBox = settings.searchBoxes?.[0] || null;
|
||||||
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
|
||||||
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
|
||||||
this.notifyingReminder = !!settings.notifyingReminderTime;
|
|
||||||
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
|
||||||
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
|
||||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
this.partnerApiServerInput =
|
this.partnerApiServerInput =
|
||||||
settings.partnerApiServer || this.partnerApiServerInput;
|
settings.partnerApiServer || this.partnerApiServerInput;
|
||||||
@@ -1132,87 +1033,6 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async showNewActivityNotificationInfo(): Promise<void> {
|
|
||||||
this.notify.confirm(
|
|
||||||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
|
||||||
async () => {
|
|
||||||
await (this.$router as Router).push({
|
|
||||||
name: "help-notification-types",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async showNewActivityNotificationChoice(): Promise<void> {
|
|
||||||
if (!this.notifyingNewActivity) {
|
|
||||||
(
|
|
||||||
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
||||||
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
|
|
||||||
if (success) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingNewActivityTime: timeText,
|
|
||||||
});
|
|
||||||
this.notifyingNewActivity = true;
|
|
||||||
this.notifyingNewActivityTime = timeText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
|
|
||||||
if (success) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingNewActivityTime: "",
|
|
||||||
});
|
|
||||||
this.notifyingNewActivity = false;
|
|
||||||
this.notifyingNewActivityTime = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async showReminderNotificationInfo(): Promise<void> {
|
|
||||||
this.notify.confirm(
|
|
||||||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.REMINDER_INFO,
|
|
||||||
async () => {
|
|
||||||
await (this.$router as Router).push({
|
|
||||||
name: "help-notification-types",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async showReminderNotificationChoice(): Promise<void> {
|
|
||||||
if (!this.notifyingReminder) {
|
|
||||||
(
|
|
||||||
this.$refs.pushNotificationPermission as PushNotificationPermission
|
|
||||||
).open(
|
|
||||||
DIRECT_PUSH_TITLE,
|
|
||||||
async (success: boolean, timeText: string, message?: string) => {
|
|
||||||
if (success) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingReminderMessage: message,
|
|
||||||
notifyingReminderTime: timeText,
|
|
||||||
});
|
|
||||||
this.notifyingReminder = true;
|
|
||||||
this.notifyingReminderMessage = message || "";
|
|
||||||
this.notifyingReminderTime = timeText;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.notify.notificationOff(DIRECT_PUSH_TITLE, async (success) => {
|
|
||||||
if (success) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingReminderMessage: "",
|
|
||||||
notifyingReminderTime: "",
|
|
||||||
});
|
|
||||||
this.notifyingReminder = false;
|
|
||||||
this.notifyingReminderMessage = "";
|
|
||||||
this.notifyingReminderTime = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
public async toggleHideRegisterPromptOnNewContact(): Promise<void> {
|
||||||
const newSetting = !this.hideRegisterPromptOnNewContact;
|
const newSetting = !this.hideRegisterPromptOnNewContact;
|
||||||
await this.$saveSettings({
|
await this.$saveSettings({
|
||||||
@@ -1229,19 +1049,8 @@ export default class AccountViewView extends Vue {
|
|||||||
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
this.passkeyExpirationDescription = tokenExpiryTimeDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async turnOffNotifyingFlags(): Promise<void> {
|
// Note: Notification state management has been migrated to NotificationSection component
|
||||||
// should tell the push server as well
|
// which handles its own lifecycle and persistence via PlatformServiceMixin
|
||||||
await this.$saveSettings({
|
|
||||||
notifyingNewActivityTime: "",
|
|
||||||
notifyingReminderMessage: "",
|
|
||||||
notifyingReminderTime: "",
|
|
||||||
});
|
|
||||||
this.notifyingNewActivity = false;
|
|
||||||
this.notifyingNewActivityTime = "";
|
|
||||||
this.notifyingReminder = false;
|
|
||||||
this.notifyingReminderMessage = "";
|
|
||||||
this.notifyingReminderTime = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously exports the database into a downloadable JSON file.
|
* Asynchronously exports the database into a downloadable JSON file.
|
||||||
|
|||||||
@@ -831,3 +831,26 @@ export default class DIDView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dialog-overlay {
|
||||||
|
z-index: 50;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user