Compare commits
11 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3b3f3504 | ||
|
|
bb9e06c9ed | ||
|
|
a834d185e3 | ||
|
|
4e81565ab6 | ||
|
|
795eef4bda | ||
|
|
3ad8424d17 | ||
|
|
83feb5a5cf | ||
|
|
0dd97f3d7e | ||
|
|
bb32c1f7f6 | ||
|
|
3bb2d68a65 | ||
|
|
fe117cdaec |
@@ -16,9 +16,15 @@ pub async fn torrent_finished_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<TorrentFinishedQuery>,
|
||||
) -> StatusCode {
|
||||
tracing::info!("Torrent finished notification received: {} ({})", params.name, params.hash);
|
||||
tracing::info!("WEBHOOK: Received notification from rTorrent. Name: {:?}, Hash: {:?}", params.name, params.hash);
|
||||
|
||||
let message = format!("Torrent tamamlandı: {}", params.name);
|
||||
let torrent_name = if params.name.is_empty() || params.name == "$d.name=" {
|
||||
"Bilinmeyen Torrent".to_string()
|
||||
} else {
|
||||
params.name.clone()
|
||||
};
|
||||
|
||||
let message = format!("Torrent tamamlandı: {}", torrent_name);
|
||||
|
||||
// 1. Send to active SSE clients (for Toast)
|
||||
let notification = SystemNotification {
|
||||
@@ -33,9 +39,13 @@ pub async fn torrent_finished_handler(
|
||||
let push_store = state.push_store.clone();
|
||||
let title = "Torrent Tamamlandı".to_string();
|
||||
let body = message;
|
||||
let name_for_log = torrent_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::push::send_push_notification(&push_store, &title, &body).await {
|
||||
tracing::error!("Failed to send push notification from webhook: {}", e);
|
||||
tracing::info!("Attempting to send Web Push notification for torrent: {}", name_for_log);
|
||||
match crate::push::send_push_notification(&push_store, &title, &body).await {
|
||||
Ok(_) => tracing::info!("Web Push notification task completed for: {}", name_for_log),
|
||||
Err(e) => tracing::error!("Failed to send Web Push notification for {}: {:?}", name_for_log, e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ async fn main() {
|
||||
let loop_interval = if active_clients > 0 {
|
||||
Duration::from_secs(1)
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
Duration::from_secs(60)
|
||||
};
|
||||
|
||||
// 1. Fetch Torrents
|
||||
|
||||
@@ -191,11 +191,21 @@ pub async fn send_push_notification(
|
||||
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
|
||||
let err_msg = format!("{:?}", e);
|
||||
tracing::error!("Delivery failed for {}: {}", subscription.endpoint, err_msg);
|
||||
// Always remove on delivery failure (Gone, Unauthorized, etc.)
|
||||
tracing::info!("Removing problematic subscription after delivery failure: {}", subscription.endpoint);
|
||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to build push message: {}", e),
|
||||
Err(e) => {
|
||||
let err_msg = format!("{:?}", e);
|
||||
tracing::error!("Encryption/Build failed for {}: {}", subscription.endpoint, err_msg);
|
||||
// Always remove on encryption failure
|
||||
tracing::info!("Removing problematic subscription after encryption failure: {}", subscription.endpoint);
|
||||
let _ = store.remove_subscription(&subscription.endpoint).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
|
||||
|
||||
@@ -3,6 +3,7 @@ use leptos::task::spawn_local;
|
||||
use crate::components::ui::sidenav::*;
|
||||
use crate::components::ui::button::{Button, ButtonVariant, ButtonSize};
|
||||
use crate::components::ui::theme_toggle::ThemeToggle;
|
||||
use crate::components::ui::switch::Switch;
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
@@ -65,6 +66,19 @@ pub fn Sidebar() -> impl IntoView {
|
||||
username().chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||
};
|
||||
|
||||
let on_push_toggle = move |checked: bool| {
|
||||
spawn_local(async move {
|
||||
if checked {
|
||||
crate::store::subscribe_to_push_notifications().await;
|
||||
} else {
|
||||
crate::store::unsubscribe_from_push_notifications().await;
|
||||
}
|
||||
if let Ok(enabled) = crate::store::is_push_subscribed().await {
|
||||
store.push_enabled.set(enabled);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<SidenavHeader>
|
||||
<div class="flex items-center gap-2 px-2 py-4">
|
||||
@@ -133,35 +147,49 @@ pub fn Sidebar() -> impl IntoView {
|
||||
</SidenavContent>
|
||||
|
||||
<SidenavFooter>
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
|
||||
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
|
||||
{first_letter}
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
// Push Notification Toggle
|
||||
<div class="flex items-center justify-between px-2 py-1 bg-muted/20 rounded-md border border-border/50">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-foreground/70">"Bildirimler"</span>
|
||||
<span class="text-[9px] text-muted-foreground">"Web Push"</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked=Signal::from(store.push_enabled)
|
||||
on_checked_change=Callback::new(on_push_toggle)
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
|
||||
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 shadow-xs overflow-hidden">
|
||||
<div class="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0 border border-primary-foreground/10">
|
||||
{first_letter}
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="font-medium text-[11px] truncate text-foreground leading-tight">{username}</div>
|
||||
<div class="text-[9px] text-muted-foreground truncate opacity-70">"Yönetici"</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="size-7 text-destructive hover:bg-destructive/10"
|
||||
on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
if shared::server_fns::auth::logout().await.is_ok() {
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _ = window.location().set_href("/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
<Button
|
||||
variant=ButtonVariant::Ghost
|
||||
size=ButtonSize::Icon
|
||||
class="size-7 text-destructive hover:bg-destructive/10"
|
||||
on:click=move |_| {
|
||||
spawn_local(async move {
|
||||
if shared::server_fns::auth::logout().await.is_ok() {
|
||||
let window = web_sys::window().expect("window should exist");
|
||||
let _ = window.location().set_href("/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidenavFooter>
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod sheet;
|
||||
pub mod sidenav;
|
||||
pub mod skeleton;
|
||||
pub mod svg_icon;
|
||||
pub mod switch;
|
||||
pub mod table;
|
||||
pub mod theme_toggle;
|
||||
pub mod toast;
|
||||
42
frontend/src/components/ui/switch.rs
Normal file
42
frontend/src/components/ui/switch.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use leptos::prelude::*;
|
||||
use tailwind_fuse::tw_merge;
|
||||
|
||||
#[component]
|
||||
pub fn Switch(
|
||||
#[prop(into)] checked: Signal<bool>,
|
||||
#[prop(into, optional)] on_checked_change: Option<Callback<bool>>,
|
||||
#[prop(into, optional)] class: String,
|
||||
#[prop(into, optional)] disabled: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
let checked_val = move || checked.get();
|
||||
let disabled_val = move || disabled.get();
|
||||
|
||||
let track_class = move || tw_merge!(
|
||||
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
if checked_val() { "bg-primary" } else { "bg-input" },
|
||||
class.clone()
|
||||
);
|
||||
|
||||
let thumb_class = move || tw_merge!(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
if checked_val() { "translate-x-4" } else { "translate-x-0" }
|
||||
);
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked=move || checked_val().to_string()
|
||||
disabled=disabled_val
|
||||
class=track_class
|
||||
on:click=move |e| {
|
||||
e.prevent_default();
|
||||
if let Some(cb) = on_checked_change {
|
||||
cb.run(!checked_val());
|
||||
}
|
||||
}
|
||||
>
|
||||
<span class=thumb_class />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ use leptos::task::spawn_local;
|
||||
use shared::{AppEvent, GlobalStats, NotificationLevel, Torrent};
|
||||
use std::collections::HashMap;
|
||||
use struct_patch::traits::Patch;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
use crate::components::ui::toast::{ToastType, toast};
|
||||
|
||||
@@ -24,8 +26,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
||||
toast(msg, variant);
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
||||
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
||||
|
||||
@@ -54,6 +54,7 @@ pub struct TorrentStore {
|
||||
pub global_stats: RwSignal<GlobalStats>,
|
||||
pub user: RwSignal<Option<String>>,
|
||||
pub selected_torrent: RwSignal<Option<String>>,
|
||||
pub push_enabled: RwSignal<bool>,
|
||||
}
|
||||
|
||||
pub fn provide_torrent_store() {
|
||||
@@ -63,12 +64,20 @@ pub fn provide_torrent_store() {
|
||||
let global_stats = RwSignal::new(GlobalStats::default());
|
||||
let user = RwSignal::new(Option::<String>::None);
|
||||
let selected_torrent = RwSignal::new(Option::<String>::None);
|
||||
let push_enabled = RwSignal::new(false);
|
||||
|
||||
let show_browser_notification = crate::utils::notification::use_app_notification();
|
||||
|
||||
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent };
|
||||
let store = TorrentStore { torrents, filter, search_query, global_stats, user, selected_torrent, push_enabled };
|
||||
provide_context(store);
|
||||
|
||||
// Initial check for push status
|
||||
spawn_local(async move {
|
||||
if let Ok(enabled) = is_push_subscribed().await {
|
||||
push_enabled.set(enabled);
|
||||
}
|
||||
});
|
||||
|
||||
let global_stats_for_sse = global_stats;
|
||||
let torrents_for_sse = torrents;
|
||||
let show_browser_notification = show_browser_notification.clone();
|
||||
@@ -79,17 +88,12 @@ pub fn provide_torrent_store() {
|
||||
let mut disconnect_notified = false;
|
||||
|
||||
loop {
|
||||
|
||||
log::debug!("SSE: Creating EventSource...");
|
||||
let es_result = EventSource::new("/api/events");
|
||||
match es_result {
|
||||
Ok(mut es) => {
|
||||
log::debug!("SSE: EventSource created, subscribing...");
|
||||
if let Ok(mut stream) = es.subscribe("message") {
|
||||
log::debug!("SSE: Subscribed to message channel");
|
||||
let mut got_first_message = false;
|
||||
while let Some(Ok((_, msg))) = stream.next().await {
|
||||
log::debug!("SSE: Received message");
|
||||
if !got_first_message {
|
||||
got_first_message = true;
|
||||
backoff_ms = 1000;
|
||||
@@ -101,47 +105,30 @@ pub fn provide_torrent_store() {
|
||||
}
|
||||
|
||||
if let Some(data_str) = msg.data().as_string() {
|
||||
// Decode Base64
|
||||
match BASE64.decode(&data_str) {
|
||||
Ok(bytes) => {
|
||||
// Deserialize MessagePack
|
||||
match rmp_serde::from_slice::<AppEvent>(&bytes) {
|
||||
Ok(event) => {
|
||||
match event {
|
||||
AppEvent::FullList(list, _) => {
|
||||
log::info!("SSE: Received FullList with {} torrents", list.len());
|
||||
torrents_for_sse.update(|map| {
|
||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
||||
map.retain(|hash, _| new_hashes.contains(hash));
|
||||
for new_torrent in list {
|
||||
map.insert(new_torrent.hash.clone(), new_torrent);
|
||||
}
|
||||
});
|
||||
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
|
||||
}
|
||||
AppEvent::Update(patch) => {
|
||||
let hash_opt = patch.hash.clone();
|
||||
if let Some(hash) = hash_opt {
|
||||
torrents_for_sse.update(|map| {
|
||||
if let Some(t) = map.get_mut(&hash) {
|
||||
t.apply(patch);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
||||
AppEvent::Notification(n) => {
|
||||
show_toast(n.level.clone(), n.message.clone());
|
||||
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||
show_browser_notification("VibeTorrent", &n.message);
|
||||
}
|
||||
}
|
||||
if let Ok(bytes) = BASE64.decode(&data_str) {
|
||||
if let Ok(event) = rmp_serde::from_slice::<AppEvent>(&bytes) {
|
||||
match event {
|
||||
AppEvent::FullList(list, _) => {
|
||||
torrents_for_sse.update(|map| {
|
||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
||||
map.retain(|hash, _| new_hashes.contains(hash));
|
||||
for new_torrent in list { map.insert(new_torrent.hash.clone(), new_torrent); }
|
||||
});
|
||||
}
|
||||
AppEvent::Update(patch) => {
|
||||
if let Some(hash) = patch.hash.clone() {
|
||||
torrents_for_sse.update(|map| { if let Some(t) = map.get_mut(&hash) { t.apply(patch); } });
|
||||
}
|
||||
}
|
||||
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
||||
AppEvent::Notification(n) => {
|
||||
show_toast(n.level.clone(), n.message.clone());
|
||||
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||
show_browser_notification("VibeTorrent", &n.message);
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("SSE: Failed to deserialize MessagePack: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("SSE: Failed to decode Base64: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,13 +145,106 @@ pub fn provide_torrent_store() {
|
||||
}
|
||||
}
|
||||
}
|
||||
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
|
||||
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
||||
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn subscribe_to_push_notifications() {
|
||||
// ...
|
||||
pub async fn is_push_subscribed() -> Result<bool, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let navigator = window.navigator();
|
||||
let sw_container = navigator.service_worker();
|
||||
|
||||
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().map_err(|e| format!("{:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?
|
||||
.dyn_into::<web_sys::ServiceWorkerRegistration>()
|
||||
.map_err(|_| "not a registration")?;
|
||||
|
||||
let push_manager = registration.push_manager().map_err(|e| format!("{:?}", e))?;
|
||||
let subscription = wasm_bindgen_futures::JsFuture::from(push_manager.get_subscription().map_err(|e| format!("{:?}", e))?)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
|
||||
Ok(!subscription.is_null())
|
||||
}
|
||||
|
||||
pub async fn subscribe_to_push_notifications() {
|
||||
let window = web_sys::window().expect("no window");
|
||||
let navigator = window.navigator();
|
||||
let sw_container = navigator.service_worker();
|
||||
|
||||
let registration = match wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await {
|
||||
Ok(reg) => reg.dyn_into::<web_sys::ServiceWorkerRegistration>().expect("not a reg"),
|
||||
Err(e) => { log::error!("SW Ready Error: {:?}", e); return; }
|
||||
};
|
||||
|
||||
// 1. Get Public Key from Backend
|
||||
let public_key_res: Result<String, _> = shared::server_fns::push::get_public_key().await;
|
||||
let public_key = match public_key_res {
|
||||
Ok(key) => key,
|
||||
Err(e) => { log::error!("Failed to get public key: {:?}", e); return; }
|
||||
};
|
||||
|
||||
// 2. Convert base64 key to Uint8Array
|
||||
let decoded_key = BASE64_URL.decode(public_key.trim()).expect("invalid public key");
|
||||
let key_array = js_sys::Uint8Array::from(&decoded_key[..]);
|
||||
|
||||
// 3. Prepare Options
|
||||
let mut options = web_sys::PushSubscriptionOptionsInit::new();
|
||||
options.set_user_visible_only(true);
|
||||
options.set_application_server_key(&key_array.into());
|
||||
|
||||
// 4. Subscribe
|
||||
let push_manager = registration.push_manager().expect("no push manager");
|
||||
match wasm_bindgen_futures::JsFuture::from(push_manager.subscribe_with_options(&options).expect("subscribe failed")).await {
|
||||
Ok(subscription) => {
|
||||
let sub_js = subscription.clone();
|
||||
|
||||
// Use JS to extract JSON string representation
|
||||
let json_str = js_sys::JSON::stringify(&sub_js).expect("stringify failed").as_string().expect("not a string");
|
||||
let sub_obj: serde_json::Value = serde_json::from_str(&json_str).expect("serde from str failed");
|
||||
|
||||
let endpoint = sub_obj["endpoint"].as_str().expect("no endpoint").to_string();
|
||||
let p256dh = sub_obj["keys"]["p256dh"].as_str().expect("no p256dh").to_string();
|
||||
let auth = sub_obj["keys"]["auth"].as_str().expect("no auth").to_string();
|
||||
|
||||
// 5. Save to Backend
|
||||
match shared::server_fns::push::subscribe_push(endpoint, p256dh, auth).await {
|
||||
Ok(_) => {
|
||||
log::info!("Push subscription saved successfully");
|
||||
toast_success("Bildirimler aktif edildi");
|
||||
}
|
||||
Err(e) => log::error!("Failed to save subscription: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Subscription Error: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unsubscribe_from_push_notifications() {
|
||||
let window = web_sys::window().expect("no window");
|
||||
let sw_container = window.navigator().service_worker();
|
||||
|
||||
let registration = wasm_bindgen_futures::JsFuture::from(sw_container.ready().expect("sw not ready")).await
|
||||
.unwrap().dyn_into::<web_sys::ServiceWorkerRegistration>().unwrap();
|
||||
|
||||
let push_manager = registration.push_manager().unwrap();
|
||||
if let Ok(sub_future) = push_manager.get_subscription() {
|
||||
if let Ok(subscription) = wasm_bindgen_futures::JsFuture::from(sub_future).await {
|
||||
if !subscription.is_null() {
|
||||
let sub = subscription.dyn_into::<web_sys::PushSubscription>().unwrap();
|
||||
let endpoint = sub.endpoint();
|
||||
|
||||
// 1. Unsubscribe in Browser
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(sub.unsubscribe().unwrap()).await;
|
||||
|
||||
// 2. Remove from Backend
|
||||
let _ = shared::server_fns::push::unsubscribe_push(endpoint).await;
|
||||
log::info!("Push subscription removed");
|
||||
show_toast(NotificationLevel::Info, "Bildirimler kapatıldı");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,13 @@ pub async fn subscribe_push(
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to save subscription: {}", e)))
|
||||
}
|
||||
|
||||
#[server(UnsubscribePush, "/api/server_fns")]
|
||||
pub async fn unsubscribe_push(endpoint: String) -> Result<(), ServerFnError> {
|
||||
let db_ctx = expect_context::<crate::DbContext>();
|
||||
db_ctx
|
||||
.db
|
||||
.remove_push_subscription(&endpoint)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to remove subscription: {}", e)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user