mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
PWA notification
This commit is contained in:
parent
be72eb3922
commit
82677a1bd6
12 changed files with 345 additions and 146 deletions
|
|
@ -88,8 +88,7 @@ def push_subscribe():
|
|||
db.execute("""
|
||||
INSERT INTO push_subscriptions (mailbox_address, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
mailbox_address=excluded.mailbox_address,
|
||||
ON CONFLICT(mailbox_address, endpoint) DO UPDATE SET
|
||||
p256dh=excluded.p256dh,
|
||||
auth=excluded.auth,
|
||||
created_at=excluded.created_at
|
||||
|
|
|
|||
|
|
@ -163,7 +163,29 @@ def _migrate(conn):
|
|||
try:
|
||||
conn.execute(sql)
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mailbox_address TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE,
|
||||
UNIQUE(mailbox_address, endpoint)
|
||||
);
|
||||
INSERT OR IGNORE INTO push_subscriptions_new
|
||||
SELECT id, mailbox_address, endpoint, p256dh, auth, created_at
|
||||
FROM push_subscriptions;
|
||||
DROP TABLE push_subscriptions;
|
||||
ALTER TABLE push_subscriptions_new RENAME TO push_subscriptions;
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_mailbox ON push_subscriptions(mailbox_address);
|
||||
""")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def init_db():
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ def _dispatch(mailbox_address: str, payload: dict):
|
|||
if not private_key:
|
||||
return
|
||||
|
||||
# pywebpush expects a base64url-encoded DER private key.
|
||||
# DockFlare Master stores VAPID keys as PEM — convert if needed.
|
||||
if private_key.strip().startswith('-----'):
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key, Encoding, PrivateFormat, NoEncryption
|
||||
)
|
||||
import base64
|
||||
key_obj = load_pem_private_key(private_key.encode(), password=None)
|
||||
der = key_obj.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
|
||||
private_key = base64.urlsafe_b64encode(der).rstrip(b'=').decode()
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
from app.core.database import get_standalone_db
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ server {
|
|||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 25m;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; connect-src 'self';";
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; worker-src 'self'; connect-src 'self' https://fcm.googleapis.com https://*.googleapis.com https://cloudflareinsights.com;";
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://dockflare-mail-manager:8025/api/;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import {
|
|||
SplitterGroup, SplitterPanel, SplitterResizeHandle,
|
||||
TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal,
|
||||
} from 'radix-vue'
|
||||
import { PenSquare, Sun, Moon, LogOut } from 'lucide-vue-next'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { PenSquare, Sun, Moon, LogOut, Settings, Columns2, Maximize2 } from 'lucide-vue-next'
|
||||
import { cn } from '../../lib/utils'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import MailboxSelector from './MailboxSelector.vue'
|
||||
|
|
@ -14,6 +15,8 @@ import ComposeDialog from './ComposeDialog.vue'
|
|||
import { useMailStore } from '../../stores/mail'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
|
||||
const SettingsDialog = defineAsyncComponent(() => import('./SettingsDialog.vue'))
|
||||
|
||||
const store = useMailStore()
|
||||
const { logout } = useAuth()
|
||||
|
||||
|
|
@ -78,8 +81,8 @@ const compose = () => {
|
|||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
|
||||
@click="store.toggleViewMode()"
|
||||
>
|
||||
<Columns v-if="store.viewMode === 'full'" class="size-4" />
|
||||
<Maximize v-else class="size-4" />
|
||||
<Columns2 v-if="store.viewMode === 'full'" class="size-4" />
|
||||
<Maximize2 v-else class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
|
|
@ -105,6 +108,22 @@ const compose = () => {
|
|||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<!-- Settings -->
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex-shrink-0"
|
||||
@click="store.isSettingsOpen = true"
|
||||
>
|
||||
<Settings class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="bottom" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Settings
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<!-- Logout -->
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
|
|
@ -146,6 +165,16 @@ const compose = () => {
|
|||
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">{{ store.isDark ? 'Light mode' : 'Dark mode' }}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="store.isSettingsOpen = true">
|
||||
<Settings class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">Settings</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent transition-colors" @click="logout">
|
||||
|
|
@ -214,5 +243,6 @@ const compose = () => {
|
|||
</SplitterGroup>
|
||||
|
||||
<ComposeDialog />
|
||||
<SettingsDialog />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
|
|
|||
40
webmail/src/components/mail/SettingsDialog.vue
Normal file
40
webmail/src/components/mail/SettingsDialog.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import SettingsContent from '../settings/SettingsContent.vue'
|
||||
|
||||
const store = useMailStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="store.isSettingsOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
@click.self="store.isSettingsOpen = false"
|
||||
>
|
||||
<div class="relative w-full max-w-lg rounded-xl border bg-background shadow-xl mx-4 max-h-[85vh] overflow-y-auto">
|
||||
<button
|
||||
class="absolute right-4 top-4 inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
@click="store.isSettingsOpen = false"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
<SettingsContent />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
134
webmail/src/components/settings/SettingsContent.vue
Normal file
134
webmail/src/components/settings/SettingsContent.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useInstallPrompt } from '@/composables/useInstallPrompt'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePushSubscription } from '@/composables/usePushSubscription'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { mailApi } from '@/api/mail'
|
||||
|
||||
const { canInstall, promptInstall } = useInstallPrompt()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const push = usePushSubscription()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const notificationPreview = ref(true)
|
||||
const previewLoading = ref(false)
|
||||
|
||||
watch(
|
||||
() => mailStore.currentMailbox,
|
||||
async (address) => {
|
||||
if (!address) return
|
||||
try {
|
||||
const res = await mailApi.getMailboxPreferences(address)
|
||||
notificationPreview.value = res.data.notification_preview
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function togglePreview() {
|
||||
if (!mailStore.currentMailbox || previewLoading.value) return
|
||||
previewLoading.value = true
|
||||
const next = !notificationPreview.value
|
||||
try {
|
||||
await mailApi.updateMailboxPreferences(mailStore.currentMailbox, { notification_preview: next })
|
||||
notificationPreview.value = next
|
||||
} catch { /* ignore */ } finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-xl font-semibold mb-5">Settings</h1>
|
||||
|
||||
<div v-if="canInstall" class="mb-4 rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium">Install App</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">
|
||||
Install DockFlare Mail as a desktop app for faster access and desktop notifications.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="promptInstall"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium">Notifications</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">
|
||||
Get notified when new mail arrives, even when the app is closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="notificationsStore.isDenied">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Notifications are blocked. Enable them in your browser or OS settings.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!notificationsStore.isGranted">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="notificationsStore.requestPermission"
|
||||
>
|
||||
Enable Notifications
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted-foreground">Permission granted.</p>
|
||||
|
||||
<div v-if="push.isSupported" class="space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm">Background push (all mailboxes)</span>
|
||||
<button
|
||||
v-if="!push.isSubscribed.value"
|
||||
:disabled="push.isLoading.value"
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
@click="push.subscribe()"
|
||||
>
|
||||
{{ push.isLoading.value ? 'Enabling…' : 'Enable' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="push.isLoading.value"
|
||||
class="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
|
||||
@click="push.unsubscribe()"
|
||||
>
|
||||
{{ push.isLoading.value ? 'Disabling…' : 'Disable' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="push.error.value" class="text-xs text-destructive">{{ push.error.value }}</p>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
Background push is not supported in this browser.
|
||||
</p>
|
||||
|
||||
<div v-if="mailStore.currentMailbox" class="flex items-center gap-3 pt-1">
|
||||
<span class="text-sm">Show subject & sender in notifications</span>
|
||||
<button
|
||||
:disabled="previewLoading"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 disabled:opacity-50"
|
||||
:class="notificationPreview ? 'bg-primary' : 'bg-muted'"
|
||||
role="switch"
|
||||
:aria-checked="notificationPreview"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200"
|
||||
:class="notificationPreview ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground">Additional settings are managed in DockFlare Master.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { mailApi } from '@/api/mail'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
|
||||
interface MailboxStatus {
|
||||
|
|
@ -19,7 +18,6 @@ function updateBadge(count: number) {
|
|||
}
|
||||
|
||||
export function useMailPolling() {
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const mailStore = useMailStore()
|
||||
const lastSeen = ref<Record<string, string | null>>({})
|
||||
const initialized = ref(false)
|
||||
|
|
@ -42,8 +40,6 @@ export function useMailPolling() {
|
|||
return
|
||||
}
|
||||
|
||||
if (!notificationsStore.isGranted) return
|
||||
|
||||
for (const s of statuses) {
|
||||
const prev = lastSeen.value[s.address]
|
||||
if (
|
||||
|
|
@ -51,7 +47,21 @@ export function useMailPolling() {
|
|||
(prev === undefined || prev === null || s.latest_received_at > prev)
|
||||
) {
|
||||
lastSeen.value[s.address] = s.latest_received_at
|
||||
fireNotification(s.address, s.unread_count)
|
||||
|
||||
if (s.address === mailStore.currentMailbox && mailStore.currentFolder) {
|
||||
try {
|
||||
const mRes = await mailApi.getMessages(s.address, {
|
||||
folder: mailStore.currentFolder,
|
||||
order: mailStore.sortOrder,
|
||||
})
|
||||
const payload = mRes.data
|
||||
mailStore.messages = Array.isArray(payload) ? payload : payload.items || []
|
||||
} catch { /* network error — skip */ }
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
fireNotification(s.address, s.unread_count)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ref } from 'vue'
|
||||
import apiClient from '@/api/client'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
|
||||
const isSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window
|
||||
|
||||
|
|
@ -14,6 +15,8 @@ function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
|
|||
export function usePushSubscription() {
|
||||
const isSubscribed = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const checkSubscription = async () => {
|
||||
if (!isSupported) return
|
||||
|
|
@ -22,23 +25,42 @@ export function usePushSubscription() {
|
|||
isSubscribed.value = !!sub
|
||||
}
|
||||
|
||||
const subscribe = async (mailboxAddress: string) => {
|
||||
const subscribe = async () => {
|
||||
if (!isSupported) return
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await apiClient.get('/notifications/vapid-key')
|
||||
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(data.public_key),
|
||||
})
|
||||
const subJson = sub.toJSON()
|
||||
await apiClient.post('/notifications/subscribe', {
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
mailbox_address: mailboxAddress,
|
||||
})
|
||||
|
||||
let addresses: string[] = mailStore.mailboxes.map((m: any) => m.address)
|
||||
if (addresses.length === 0) {
|
||||
const statusRes = await apiClient.get('/mailboxes/status')
|
||||
addresses = (statusRes.data as any[]).map((s) => s.address)
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
error.value = 'No mailboxes found'
|
||||
return
|
||||
}
|
||||
|
||||
for (const address of addresses) {
|
||||
await apiClient.post('/notifications/subscribe', {
|
||||
endpoint: subJson.endpoint,
|
||||
keys: subJson.keys,
|
||||
mailbox_address: address,
|
||||
})
|
||||
}
|
||||
isSubscribed.value = true
|
||||
} catch (err: any) {
|
||||
console.error('Push subscribe failed:', err)
|
||||
error.value = err?.message ?? 'Subscription failed'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
|
@ -47,6 +69,7 @@ export function usePushSubscription() {
|
|||
const unsubscribe = async () => {
|
||||
if (!isSupported) return
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
|
|
@ -57,6 +80,9 @@ export function usePushSubscription() {
|
|||
await sub.unsubscribe()
|
||||
}
|
||||
isSubscribed.value = false
|
||||
} catch (err: any) {
|
||||
console.error('Push unsubscribe failed:', err)
|
||||
error.value = err?.message ?? 'Unsubscribe failed'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
|
@ -64,5 +90,5 @@ export function usePushSubscription() {
|
|||
|
||||
checkSubscription()
|
||||
|
||||
return { isSubscribed, isLoading, isSupported, subscribe, unsubscribe }
|
||||
return { isSubscribed, isLoading, isSupported, error, subscribe, unsubscribe }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const useMailStore = defineStore('mail', () => {
|
|||
const currentMessage = ref<any>(null)
|
||||
const isComposeOpen = ref(false)
|
||||
const isComposeFullView = ref(false)
|
||||
const isSettingsOpen = ref(false)
|
||||
const composeDefaults = ref<{ to?: string; subject?: string; body?: string; quotedHtml?: string; draftId?: number } | null>(null)
|
||||
const composeBody = ref('')
|
||||
const activeTab = ref<'all' | 'unread' | 'starred'>('all')
|
||||
|
|
@ -50,7 +51,7 @@ export const useMailStore = defineStore('mail', () => {
|
|||
mailboxes, currentMailbox,
|
||||
folders, currentFolder, currentFolderObj,
|
||||
messages, currentMessage,
|
||||
isComposeOpen, isComposeFullView, composeDefaults, composeBody,
|
||||
isComposeOpen, isComposeFullView, isSettingsOpen, composeDefaults, composeBody,
|
||||
activeTab, isCollapsed,
|
||||
sortOrder, isDark, toggleTheme,
|
||||
viewMode, toggleViewMode,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMail } from '../composables/useMail'
|
||||
import { useMailPolling } from '../composables/useMailPolling'
|
||||
import { useNotificationsStore } from '../stores/notifications'
|
||||
import { mailApi } from '../api/mail'
|
||||
import MailLayout from '../components/mail/MailLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { store, loadMailboxes } = useMail()
|
||||
const mailStore = store
|
||||
const notificationsStore = useNotificationsStore()
|
||||
useMailPolling()
|
||||
|
||||
const showNotifPrompt = ref(false)
|
||||
|
||||
const loadMessages = async (addr: string, folder: string) => {
|
||||
if (!addr || !folder) return
|
||||
try {
|
||||
|
|
@ -22,6 +28,20 @@ const loadMessages = async (addr: string, folder: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
await notificationsStore.requestPermission()
|
||||
showNotifPrompt.value = false
|
||||
localStorage.setItem('notif_prompted', '1')
|
||||
if (notificationsStore.isGranted) {
|
||||
mailStore.isSettingsOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
function dismissPrompt() {
|
||||
showNotifPrompt.value = false
|
||||
localStorage.setItem('notif_prompted', '1')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMailboxes()
|
||||
|
||||
|
|
@ -31,6 +51,10 @@ onMounted(async () => {
|
|||
if (found) store.currentMailbox = mailboxParam
|
||||
}
|
||||
|
||||
if (Notification.permission === 'default' && !localStorage.getItem('notif_prompted')) {
|
||||
showNotifPrompt.value = true
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', (ev: MessageEvent) => {
|
||||
if (ev.data?.type === 'NOTIFICATION_CLICK' && ev.data.mailbox) {
|
||||
|
|
@ -98,5 +122,28 @@ watch(() => store.currentMessage, async (msg) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<MailLayout />
|
||||
<div class="relative h-full">
|
||||
<MailLayout />
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="showNotifPrompt"
|
||||
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 rounded-xl border bg-background shadow-lg px-5 py-3.5 text-sm"
|
||||
>
|
||||
<span class="text-muted-foreground">Enable notifications for new mail?</span>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="enableNotifications"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="dismissPrompt"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,130 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useInstallPrompt } from '@/composables/useInstallPrompt'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import { usePushSubscription } from '@/composables/usePushSubscription'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { mailApi } from '@/api/mail'
|
||||
|
||||
const { canInstall, promptInstall } = useInstallPrompt()
|
||||
const notificationsStore = useNotificationsStore()
|
||||
const push = usePushSubscription()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
const notificationPreview = ref(true)
|
||||
const previewLoading = ref(false)
|
||||
|
||||
watch(
|
||||
() => mailStore.currentMailbox,
|
||||
async (address) => {
|
||||
if (!address) return
|
||||
try {
|
||||
const res = await mailApi.getMailboxPreferences(address)
|
||||
notificationPreview.value = res.data.notification_preview
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function togglePreview() {
|
||||
if (!mailStore.currentMailbox || previewLoading.value) return
|
||||
previewLoading.value = true
|
||||
const next = !notificationPreview.value
|
||||
try {
|
||||
await mailApi.updateMailboxPreferences(mailStore.currentMailbox, { notification_preview: next })
|
||||
notificationPreview.value = next
|
||||
} catch { /* ignore */ } finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
import SettingsContent from '@/components/settings/SettingsContent.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 max-w-2xl">
|
||||
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
|
||||
|
||||
<div v-if="canInstall" class="mb-4 rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium">Install App</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">
|
||||
Install DockFlare Mail as a desktop app for faster access and desktop notifications.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="promptInstall"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-lg border p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium">Notifications</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">
|
||||
Get notified when new mail arrives, even when the app is closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="notificationsStore.isDenied">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Notifications are blocked. Enable them in your browser or OS settings.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!notificationsStore.isGranted">
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="notificationsStore.requestPermission"
|
||||
>
|
||||
Enable Notifications
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted-foreground">Permission granted.</p>
|
||||
<div v-if="push.isSupported && mailStore.currentMailbox" class="flex items-center gap-3">
|
||||
<span class="text-sm">Background push for {{ mailStore.currentMailbox }}</span>
|
||||
<button
|
||||
v-if="!push.isSubscribed.value"
|
||||
:disabled="push.isLoading.value"
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
@click="push.subscribe(mailStore.currentMailbox)"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="push.isLoading.value"
|
||||
class="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
|
||||
@click="push.unsubscribe()"
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="!push.isSupported" class="text-sm text-muted-foreground">
|
||||
Background push is not supported in this browser.
|
||||
</p>
|
||||
|
||||
<div v-if="mailStore.currentMailbox" class="flex items-center gap-3 pt-1">
|
||||
<span class="text-sm">Show subject & sender in notifications</span>
|
||||
<button
|
||||
:disabled="previewLoading"
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 disabled:opacity-50"
|
||||
:class="notificationPreview ? 'bg-primary' : 'bg-muted'"
|
||||
role="switch"
|
||||
:aria-checked="notificationPreview"
|
||||
@click="togglePreview"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform duration-200"
|
||||
:class="notificationPreview ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground">Additional settings are managed in DockFlare Master.</p>
|
||||
<SettingsContent />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue