Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
// persisted value and the device-side plumbing; this mirrors it for the UI.
private val _pushEnabled = MutableStateFlow(false)
val pushEnabled: StateFlow<Boolean> = _pushEnabled.asStateFlow()
private val _pushAlertTypes = MutableStateFlow<List<Pair<String, String>>>(emptyList())
val pushAlertTypes = _pushAlertTypes.asStateFlow()

// The hashtags in a post, to pick which one's timeline to open (the core
// sends this only when a post has several; one tag opens directly).
Expand Down Expand Up @@ -357,6 +359,13 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
"settings" -> {
val settings = e.optJSONObject("settings")
_settings.value = settings
val types = e.optJSONArray("push_alert_types")
_pushAlertTypes.value = buildList {
if (types != null) for (i in 0 until types.length()) {
val type = types.getJSONObject(i)
add(type.getString("key") to type.getString("label"))
}
}
val sp = e.optJSONArray("soundpacks")
_soundpacks.value = buildList {
if (sp != null) for (i in 0 until sp.length()) add(sp.optString(i))
Expand Down Expand Up @@ -603,6 +612,9 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
"push_unsubscribe_result" -> {
pushToggleInFlight = false
}
"push_update_alerts_result" -> {
// Keep saved choices and the master switch. The core announces failures.
}

"hashtag_timeline_picker" -> {
val arr = e.optJSONArray("tags")
Expand Down Expand Up @@ -866,6 +878,7 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
return
}
PushManager.enable(app) { sub ->
if (!PushManager.isEnabled(app)) return@enable
// Send whatever we got, including nothing: no Firebase token or a
// relay that wouldn't answer means an empty endpoint, and the core
// turns that into the same spoken failure as any other, so no
Expand All @@ -886,7 +899,7 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
fun refreshPush() {
syncPushEnabled()
PushManager.refreshIfEnabled(getApplication()) { sub ->
if (sub == null) return@refreshIfEnabled
if (sub == null || !PushManager.isEnabled(getApplication())) return@refreshIfEnabled
core.dispatch("push_subscribe") {
put("endpoint", sub.endpoint)
put("p256dh", sub.p256dh)
Expand All @@ -895,6 +908,14 @@ class CoreViewModel(app: Application) : AndroidViewModel(app) {
}
}

/** Save one push type without replacing other pending setting changes. */
fun setPushAlert(key: String, enabled: Boolean) {
core.dispatch("push_update_alerts") {
put("alerts", JSONObject().put(key, enabled))
put("apply_to_subscriptions", PushManager.isEnabled(getApplication()))
}
}

/** Close a tab: select it (so it's current), then dismiss it. */
fun closeTimeline(index: Int) {
selectTimeline(index)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ fun SettingsScreen(viewModel: CoreViewModel, onClose: () -> Unit) {
when {
speechList != null -> SpeechFieldEditor(s, speechList!!, viewModel)
panel == "general" -> GeneralPanel(s, viewModel)
panel == "notifications" -> NotificationsPanel(viewModel)
panel == "notifications" -> NotificationsPanel(s, viewModel)
panel == "timelines" -> TimelinesPanel(s, viewModel)
panel == "audio" -> AudioPanel(s, soundpacks, viewModel)
panel == "earcons" -> EarconsPanel(s, viewModel)
Expand Down Expand Up @@ -247,8 +247,9 @@ private fun GeneralPanel(s: JSONObject, vm: CoreViewModel) {
* a core setting, so it doesn't come through the settings JSON like the others.
*/
@Composable
private fun NotificationsPanel(vm: CoreViewModel) {
private fun NotificationsPanel(s: JSONObject, vm: CoreViewModel) {
val enabled by vm.pushEnabled.collectAsStateWithLifecycle()
val types by vm.pushAlertTypes.collectAsStateWithLifecycle()
val context = LocalContext.current

// The stored preference is the truth; pick it up when the panel opens.
Expand Down Expand Up @@ -278,8 +279,14 @@ private fun NotificationsPanel(vm: CoreViewModel) {
}
HelpText(
"Get notified of mentions, boosts, favorites, follows and more while FastSMRW " +
"is closed. Mastodon accounts only."
"is closed. These choices apply to all Mastodon accounts on this device."
)
val alerts = s.optJSONObject("push_alerts")
types.forEach { (key, label) ->
SwitchRow(label, alerts?.optBoolean(key, true) ?: true) { on ->
vm.setPushAlert(key, on)
}
}
}

@Composable
Expand Down
18 changes: 18 additions & 0 deletions apple/shared/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class AppState {
/// (with edits) on update_settings — the core re-applies defaults for any
/// missing key, so we must always send the whole object.
private(set) var settingsRaw: [String: Any] = [:]
private(set) var pushAlertTypes: [(key: String, label: String)] = []
private(set) var soundpacks: [String] = []
/// Output devices the core's mixer can play sound effects through (desktop
/// settings; iOS routes audio itself and ignores this).
Expand Down Expand Up @@ -132,6 +133,12 @@ final class AppState {
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["event"] as? String == "settings" {
if let s = obj["settings"] as? [String: Any] { settingsRaw = s }
if let types = obj["push_alert_types"] as? [[String: String]] {
pushAlertTypes = types.compactMap { item in
guard let key = item["key"], let label = item["label"] else { return nil }
return (key: key, label: label)
}
}
if let packs = obj["soundpacks"] as? [String] { soundpacks = packs }
if let devices = obj["sound_devices"] as? [String] { soundDevices = devices }
onSettings?()
Expand Down Expand Up @@ -235,6 +242,9 @@ final class AppState {
onPushSubscribeResult?(e.ok)
case let .pushUnsubscribeResult(e):
onPushUnsubscribeResult?(e.ok)
case .pushUpdateAlertsResult:
// Saved choices remain selected on failure; the core announces details.
break
case let .followedHashtags(e):
onFollowedHashtags?(e)
case let .trendingHashtags(e):
Expand Down Expand Up @@ -418,6 +428,14 @@ final class AppState {
client.send("push_unsubscribe", ["announce": announce])
}

func setPushAlert(_ key: String, enabled: Bool, applyToSubscriptions: Bool) {
var alerts = settingsRaw["push_alerts"] as? [String: Bool] ?? [:]
alerts[key] = enabled
settingsRaw["push_alerts"] = alerts
client.send("push_update_alerts", ["alerts": [key: enabled],
"apply_to_subscriptions": applyToSubscriptions])
}

// Followed hashtags (Mastodon)
func followHashtagPrompt(id: String) { client.send("follow_hashtag_prompt", ["id": id]) }
func followHashtag(name: String) { client.send("follow_hashtag", ["name": name]) }
Expand Down
3 changes: 3 additions & 0 deletions apple/shared/CoreEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,7 @@ enum CoreEvent {
case hashtagTimelinePicker(HashtagTimelinePicker)
case pushSubscribeResult(PushResult)
case pushUnsubscribeResult(PushResult)
case pushUpdateAlertsResult(PushResult)
case followedHashtags(FollowedHashtags)
case trendingHashtags(FollowedHashtags)
case aliasPrompt(AliasPrompt)
Expand Down Expand Up @@ -646,6 +647,8 @@ enum CoreEvent {
return decode(PushResult.self).map(CoreEvent.pushSubscribeResult)
case "push_unsubscribe_result":
return decode(PushResult.self).map(CoreEvent.pushUnsubscribeResult)
case "push_update_alerts_result":
return decode(PushResult.self).map(CoreEvent.pushUpdateAlertsResult)
case "followed_hashtags": return decode(FollowedHashtags.self).map(CoreEvent.followedHashtags)
case "trending_hashtags": return decode(FollowedHashtags.self).map(CoreEvent.trendingHashtags)
case "alias_prompt": return decode(AliasPrompt.self).map(CoreEvent.aliasPrompt)
Expand Down
2 changes: 1 addition & 1 deletion build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ if defined ISCC (
REM ---- 3) optional: tests ----
if "%RUN_TESTS%"=="1" (
echo Compiling tests...
cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib
cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_push.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib
if errorlevel 1 goto error
echo Running tests...
"%BUILD%\fastsm_tests.exe"
Expand Down
37 changes: 37 additions & 0 deletions core/include/fastsm/models/push_alerts.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#pragma once

#include <array>

namespace fastsm {

// Device-wide preferences shared by every push-capable account. Defaults keep
// the alert types enabled by older versions of the mobile clients.
struct PushAlerts {
bool mention = true;
bool favourite = true;
bool reblog = true;
bool follow = true;
bool follow_request = true;
bool poll = true;
bool status = true;
bool update = true;
};

struct PushAlertDef {
const char* key;
const char* label;
bool PushAlerts::* enabled;
};

inline constexpr std::array<PushAlertDef, 8> push_alert_catalog = {{
{"mention", "Mentions", &PushAlerts::mention},
{"favourite", "Favorites", &PushAlerts::favourite},
{"reblog", "Boosts", &PushAlerts::reblog},
{"follow", "Follows", &PushAlerts::follow},
{"follow_request", "Follow requests", &PushAlerts::follow_request},
{"poll", "Poll results", &PushAlerts::poll},
{"status", "Posts from accounts you've enabled notifications for", &PushAlerts::status},
{"update", "Post edits", &PushAlerts::update},
}};

} // namespace fastsm
3 changes: 2 additions & 1 deletion core/include/fastsm/platform/mastodon/mastodon_account.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ class MastodonAccount : public SocialAccount {
std::vector<FollowedTag> trending_hashtags() override;

PushSubscribe subscribe_push(const std::string& endpoint, const std::string& p256dh,
const std::string& auth) override;
const std::string& auth, const PushAlerts& alerts) override;
PushSubscribe update_push_alerts(const PushAlerts& alerts) override;
bool unsubscribe_push() override;

std::vector<TimelineList> lists() override;
Expand Down
8 changes: 7 additions & 1 deletion core/include/fastsm/platform/social_account.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include "fastsm/models/push_alerts.hpp"

#include <optional>
#include <string>
#include <vector>
Expand Down Expand Up @@ -331,11 +333,15 @@ class SocialAccount {
// base64url. Runs synchronously on the worker thread.
virtual PushSubscribe subscribe_push(const std::string& /*endpoint*/,
const std::string& /*p256dh*/,
const std::string& /*auth*/) {
const std::string& /*auth*/, const PushAlerts& /*alerts*/) {
return PushSubscribe::Failed;
}
// Remove this account's push subscription. Return success.
virtual bool unsubscribe_push() { return false; }
// Update only alert types, preserving the subscription endpoint and keys.
virtual PushSubscribe update_push_alerts(const PushAlerts& /*alerts*/) {
return PushSubscribe::Failed;
}

// --- Server-side keyword filters (optional; Mastodon /api/v2/filters) ---
// Whether this platform exposes managed server filters at all (Mastodon yes,
Expand Down
3 changes: 3 additions & 0 deletions core/include/fastsm/session/core_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class CoreSession {
// the setting, so a quiet renewal at startup says nothing.
void cmd_push_subscribe(const nlohmann::json& cmd);
void cmd_push_unsubscribe(const nlohmann::json& cmd); // {announce?}
// {alerts:{type:bool,...}, apply_to_subscriptions?:false, announce?:true}
// Saves a patch; updates existing subscriptions only when the device enables push.
void cmd_push_update_alerts(const nlohmann::json& cmd);
// Emit a push result event and, when announce is set, speak the outcome.
void emit_push_result(const char* event, bool ok, const std::string& reason, bool announce);
// {id} -> spawns the post's only hashtag timeline, or emits a
Expand Down
2 changes: 2 additions & 0 deletions core/include/fastsm/store/app_settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
#include <string>

#include "fastsm/presentation/speech_settings.hpp"
#include "fastsm/models/push_alerts.hpp"

namespace fastsm::store {

// User preferences. Grows over time (Mac has many more); for now the pieces M1
// needs plus the configurable speech field order/visibility.
struct AppSettings {
PushAlerts push_alerts;
bool sounds_enabled = true;
int sound_volume = 100; // master earcon/soundpack volume, 0-100 percent
int media_volume = 100; // audio-attachment playback volume, 0-100 percent
Expand Down
33 changes: 22 additions & 11 deletions core/src/platform/mastodon/mastodon_account.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1264,22 +1264,18 @@ bool MastodonAccount::unfollow_hashtag(const std::string& name) {

PushSubscribe MastodonAccount::subscribe_push(const std::string& endpoint,
const std::string& p256dh,
const std::string& auth) {
const std::string& auth, const PushAlerts& alerts) {
// POST /api/v1/push/subscription. Replaces any existing subscription for
// this access token. Subscribe to every alert type; the UI can refine later.
const std::vector<std::pair<std::string, std::string>> params = {
// this access token. Always apply the saved device-wide alert preferences.
std::vector<std::pair<std::string, std::string>> params = {
{"subscription[endpoint]", endpoint},
{"subscription[keys][p256dh]", p256dh},
{"subscription[keys][auth]", auth},
{"data[alerts][mention]", "true"},
{"data[alerts][favourite]", "true"},
{"data[alerts][reblog]", "true"},
{"data[alerts][follow]", "true"},
{"data[alerts][follow_request]", "true"},
{"data[alerts][poll]", "true"},
{"data[alerts][status]", "true"},
{"data[alerts][update]", "true"},
};
for (const auto& def : push_alert_catalog) {
params.emplace_back(std::string("data[alerts][") + def.key + "]",
alerts.*(def.enabled) ? "true" : "false");
}
const std::string url = credentials_.instance_url + "/api/v1/push/subscription";
std::string body;
long status = 0;
Expand All @@ -1301,6 +1297,21 @@ bool MastodonAccount::unsubscribe_push() {
return request("DELETE", url, "", "", body, status);
}

PushSubscribe MastodonAccount::update_push_alerts(const PushAlerts& alerts) {
std::vector<std::pair<std::string, std::string>> params;
for (const auto& def : push_alert_catalog) {
params.emplace_back(std::string("data[alerts][") + def.key + "]",
alerts.*(def.enabled) ? "true" : "false");
}
std::string body;
long status = 0;
if (request("PUT", credentials_.instance_url + "/api/v1/push/subscription",
util::form_encode(params), "application/x-www-form-urlencoded", body, status)) {
return PushSubscribe::Ok;
}
return status == 403 ? PushSubscribe::NeedsReauth : PushSubscribe::Failed;
}

std::vector<FollowedTag> MastodonAccount::followed_hashtags() {
// GET /api/v1/followed_tags. One page (up to 200) is plenty for a manager UI.
const std::string url = credentials_.instance_url + "/api/v1/followed_tags?limit=200";
Expand Down
Loading
Loading