aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Digitigrade/GlobalSettings.php4
-rw-r--r--Digitigrade/Model.php16
-rw-r--r--Digitigrade/UserSettings.php169
-rw-r--r--locale/en_GB.json5
-rw-r--r--migrations/20250124_190913_create_user_setting.php15
-rw-r--r--routes/preferences.php25
-rw-r--r--settings.user.ini6
-rw-r--r--static/form.css6
-rw-r--r--static/icons.css3
-rw-r--r--static/theme.css2
-rw-r--r--templates/left_side_navigation.php4
-rw-r--r--templates/preferences_form.php37
-rw-r--r--templates/preferences_page.php4
-rw-r--r--templates/settings_field.php14
-rw-r--r--templates/unread_indicator.php12
15 files changed, 313 insertions, 9 deletions
diff --git a/Digitigrade/GlobalSettings.php b/Digitigrade/GlobalSettings.php
index 3e893e1..e8bbe59 100644
--- a/Digitigrade/GlobalSettings.php
+++ b/Digitigrade/GlobalSettings.php
@@ -52,6 +52,10 @@ class GlobalSettings extends Singleton {
$stmt->execute([$key, $value]);
}
+ public function setBool(string $key, bool $value) {
+ $this->set($key, $value ? 'true' : 'false');
+ }
+
/**
* Checks whether a setting is set.
* @param string $key which setting to check
diff --git a/Digitigrade/Model.php b/Digitigrade/Model.php
index 6a4fe27..9ce759a 100644
--- a/Digitigrade/Model.php
+++ b/Digitigrade/Model.php
@@ -6,7 +6,7 @@ use Digitigrade\Model\FetchableModel;
abstract class Model {
private bool $saved = false;
- protected static array $cachedLookups = [];
+ private static array $cachedLookups = [];
private static bool $memoEnabled = false;
protected function setOwnerId(int $id) {
@@ -164,7 +164,7 @@ abstract class Model {
$result = static::fromDbRow($stmt->fetch(\PDO::FETCH_ASSOC));
if (self::$memoEnabled) {
- self::$cachedLookups[$memoKey] = $result;
+ self::$cachedLookups[$memoKey] = &$result;
}
return $result;
}
@@ -201,7 +201,7 @@ abstract class Model {
return static::fromDbRow($row);
}, $stmt->fetchAll(\PDO::FETCH_ASSOC));
if (self::$memoEnabled) {
- self::$cachedLookups[$memoKey] = $result;
+ self::$cachedLookups[$memoKey] = &$result;
}
return $result;
}
@@ -229,7 +229,7 @@ abstract class Model {
$result = $stmt->fetchColumn(0);
if (self::$memoEnabled) {
- self::$cachedLookups[$memoKey] = $result;
+ self::$cachedLookups[$memoKey] = &$result;
}
return $result;
}
@@ -326,6 +326,14 @@ abstract class Model {
$tableName = self::mangleName($className);
$db->exec("DELETE FROM $tableName WHERE $where");
+
+ if (self::$memoEnabled) {
+ $key = array_search($this, self::$cachedLookups, strict: true);
+ while ($key !== false) {
+ unset(self::$cachedLookups[$key]);
+ $key = array_search($this, self::$cachedLookups, strict: true);
+ }
+ }
}
/**
diff --git a/Digitigrade/UserSettings.php b/Digitigrade/UserSettings.php
new file mode 100644
index 0000000..4a27014
--- /dev/null
+++ b/Digitigrade/UserSettings.php
@@ -0,0 +1,169 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\Model\UserAccount;
+
+class UserSettings {
+ private const DEFINITIONS_INI_PATH = __DIR__ . '/../settings.user.ini';
+
+ private \PDO $db;
+ private array $info;
+ private UserAccount $user;
+
+ public function __construct(UserAccount $user) {
+ $this->user = $user;
+ $this->db = Db::getInstance()->getPdo();
+ if (!$this->get('settings.initialised')) {
+ $this->resetAll();
+ }
+ }
+
+ /**
+ * Gets a setting's value.
+ * @param string $key which setting to get
+ * @return ?string the value, or null if not set
+ */
+ public function get(string $key): ?string {
+ $stmt = $this->db->prepare('SELECT value FROM user_setting WHERE name = ? AND user_id = ?');
+ $stmt->execute([$key, $this->user->id]);
+ return $stmt->fetchColumn(0) ?: null;
+ }
+
+ /**
+ * Gets a boolean setting's value.
+ * @param string $key which setting to get
+ * @return ?bool the value, or null if not set
+ */
+ public function getBool(string $key): ?bool {
+ return match ($this->get($key)) {
+ '', 'false' => false,
+ '1', 'true' => true,
+ default => null
+ };
+ }
+
+ /**
+ * Sets the value of a setting.
+ * @param string $key which setting to modify
+ * @param string $value the new value
+ * @return void
+ */
+ public function set(string $key, string $value) {
+ $stmt = $this->db->prepare(<<<END
+ INSERT INTO user_setting(user_id, name, value) VALUES (?, ?, ?)
+ ON CONFLICT (user_id, name) DO UPDATE SET value = EXCLUDED.value
+ END);
+ $stmt->execute([$this->user->id, $key, $value]);
+ }
+
+ public function setBool(string $key, bool $value) {
+ $this->set($key, $value ? 'true' : 'false');
+ }
+
+ /**
+ * Checks whether a setting is set.
+ * @param string $key which setting to check
+ * @return bool if it's set (has a value) or not
+ */
+ public function isset(string $key): bool {
+ $stmt = $this->db->prepare('SELECT count(*) FROM user_setting WHERE name = ? AND user_id = ?');
+ $stmt->execute([$key, $this->user->id]);
+ return $stmt->fetchColumn(0) > 0;
+ }
+
+ /**
+ * Unsets a setting.
+ * @param string $key which setting to remove
+ * @return void
+ */
+ public function unset(string $key) {
+ $stmt = $this->db->prepare('DELETE FROM user_setting WHERE name = ? AND user_id = ?');
+ $stmt->execute([$key, $this->user->id]);
+ }
+
+ /**
+ * Gets a list of all currently-set settings.
+ * @return array{key: string, value: string} array of keys to values
+ */
+ public function getAll(): array {
+ $stmt = $this->db->prepare('SELECT name, value FROM user_setting WHERE user_id = ?');
+ $stmt->execute([$this->user->id]);
+ $items = $stmt->fetchAll();
+ $result = [];
+ foreach ($items as $row) {
+ $result[$row['name']] = $row['value'];
+ }
+ return $result;
+ }
+
+ /**
+ * Gets a list of all settings in a given prefix.
+ * @param string $prefix prefix to look in (not including the trailing dot)
+ * @return array{key: string, value: string} array of keys to values
+ */
+ public function getAllInPrefix(string $prefix): array {
+ $stmt = $this->db->prepare('SELECT name, value FROM user_setting WHERE name ^@ ? AND user_id = ?');
+ $stmt->execute(["$prefix.", $this->user->id]);
+ $items = $stmt->fetchAll();
+ $result = [];
+ foreach ($items as $row) {
+ $result[$row['name']] = $row['value'];
+ }
+ return $result;
+ }
+
+ /**
+ * Gets a list of all first-level prefixes.
+ * @return string[]
+ */
+ public function getAllPrefixes(): array {
+ return array_unique(array_map(
+ fn($x) => explode('.', $x, 2)[0],
+ array_keys($this->getAll())
+ ));
+ }
+
+ /**
+ * Gets information (name, description, default value) for a given setting.
+ * @param string $key which setting to look up
+ * @return ?array{item: string, value: string} associative array of info,
+ * or null if none is available
+ */
+ public function getInfo(string $key): ?array {
+ $this->loadDefinitions();
+ return $this->info[$key] ?? null;
+ }
+
+ /**
+ * Merges new keys from the settings definition file into the settings
+ * without overwriting existing values.
+ * @return void
+ */
+ public function mergeUpdates() {
+ $this->initialise(false);
+ }
+
+ /**
+ * Sets all defined settings back to their default values.
+ * @return void
+ */
+ public function resetAll() {
+ $this->initialise(true);
+ }
+
+ private function loadDefinitions() {
+ if (!isset($this->info)) {
+ $this->info = parse_ini_file(self::DEFINITIONS_INI_PATH, process_sections: true);
+ }
+ }
+
+ private function initialise(bool $overwriteExisting) {
+ $this->loadDefinitions();
+ foreach ($this->info as $key => $setting) {
+ if ($overwriteExisting || !$this->isset($key)) {
+ $this->set($key, $setting['default']);
+ }
+ }
+ $this->set('settings.initialised', true);
+ }
+} \ No newline at end of file
diff --git a/locale/en_GB.json b/locale/en_GB.json
index b5dd0f2..41798d5 100644
--- a/locale/en_GB.json
+++ b/locale/en_GB.json
@@ -77,6 +77,11 @@
"followRequests.accepted": "Accepted!",
"followRequests.reject.action": "Reject",
"followRequests.rejected": "Rejected!",
+ "preferences.pageTitle": "Preferences",
+ "preferences.requestToFollow.name": "Request to follow",
+ "preferences.requestToFollow.description": "Lets you approve other users before they are able to follow you",
+ "preferences.smallIndicators.name": "Reduced unread indicators",
+ "preferences.smallIndicators.description": "Hides the number of unread items and just shows a small dot instead",
"globalSettings.pageTitle.instance": "Instance settings",
"globalSettings.pageTitle.about": "About this instance",
"globalSettings.categories": "Categories: ",
diff --git a/migrations/20250124_190913_create_user_setting.php b/migrations/20250124_190913_create_user_setting.php
new file mode 100644
index 0000000..5b248cc
--- /dev/null
+++ b/migrations/20250124_190913_create_user_setting.php
@@ -0,0 +1,15 @@
+<?php
+
+use \Digitigrade\Db\Migrator;
+
+Migrator::getInstance()->register(20250124_190913, function (PDO $db) {
+ // user settings table
+ $db->exec(<<<END
+ CREATE TABLE user_setting (
+ user_id bigint not null references user_account,
+ name text not null,
+ value text not null,
+ unique(user_id, name)
+ );
+ END);
+});
diff --git a/routes/preferences.php b/routes/preferences.php
new file mode 100644
index 0000000..c7e27fc
--- /dev/null
+++ b/routes/preferences.php
@@ -0,0 +1,25 @@
+<?php
+
+use Digitigrade\Model\UserAccount;
+use Digitigrade\Router;
+use Digitigrade\UserSettings;
+
+Router::getInstance()->mount('/preferences', function (array $args) {
+ $user = UserAccount::requireByCurrentSession();
+ render_template('preferences_page', ['user' => $user]);
+});
+
+Router::getInstance()->mount('/fragment/preferences', function (array $args) {
+ $user = UserAccount::requireByCurrentSession();
+ $settings = new UserSettings($user);
+ $saved = false;
+ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+ // updating the preferences
+ $user->actor->requestToFollow = $_POST['requestToFollow'] == 'true';
+ $user->actor->save();
+ $settings->set('interface.smallUnreadIndicators', $_POST['smallIndicators']);
+
+ $saved = true;
+ }
+ render_template('preferences_form', ['user' => $user, 'saved' => $saved]);
+}); \ No newline at end of file
diff --git a/settings.user.ini b/settings.user.ini
new file mode 100644
index 0000000..b82e425
--- /dev/null
+++ b/settings.user.ini
@@ -0,0 +1,6 @@
+# please put double quotes around everything. php does weird things to unquoted
+# ini values sometimes and it's not worth the debugging headache
+
+[interface.smallUnreadIndicators]
+default = "false"
+type = "boolean" \ No newline at end of file
diff --git a/static/form.css b/static/form.css
index c7a1873..95f13ac 100644
--- a/static/form.css
+++ b/static/form.css
@@ -115,3 +115,9 @@ form {
}
}
}
+
+input[type="checkbox"] {
+ width: calc(var(--icon-size) - var(--spacing-half));
+ margin: var(--spacing-single) 0;
+ aspect-ratio: 1;
+}
diff --git a/static/icons.css b/static/icons.css
index e40c30b..cc349c6 100644
--- a/static/icons.css
+++ b/static/icons.css
@@ -78,4 +78,7 @@
&.remove {
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5 13v-2h14v2z'/%3E%3C/svg%3E");
}
+ &.tune {
+ --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M11 21v-6h2v2h8v2h-8v2zm-8-2v-2h6v2zm4-4v-2H3v-2h4V9h2v6zm4-2v-2h10v2zm4-4V3h2v2h4v2h-4v2zM3 7V5h10v2z'/%3E%3C/svg%3E");
+ }
}
diff --git a/static/theme.css b/static/theme.css
index a01dd3a..161288f 100644
--- a/static/theme.css
+++ b/static/theme.css
@@ -46,4 +46,6 @@
--icon-size: 24px;
--avatar-size: 64px;
--avatar-big-size: 96px;
+
+ accent-color: var(--clr-primary);
}
diff --git a/templates/left_side_navigation.php b/templates/left_side_navigation.php
index 012cf23..4cf8b55 100644
--- a/templates/left_side_navigation.php
+++ b/templates/left_side_navigation.php
@@ -11,6 +11,10 @@ if ($user != null) {
'icon' => 'person-add-outline',
'label' => __('navigation.followRequests'),
'unreadCount' => FollowRelation::countWhere("object = ? AND status = 'pending'", [$user->actor->id])
+ ], [
+ 'href' => '/preferences',
+ 'icon' => 'tune',
+ 'label' => __('preferences.pageTitle')
]];
if ($user->isAdmin) {
$links[] = ['href' => '/admin/settings/instance', 'icon' => 'settings-outline', 'label' => __('navigation.instanceSettings')];
diff --git a/templates/preferences_form.php b/templates/preferences_form.php
new file mode 100644
index 0000000..8c8f203
--- /dev/null
+++ b/templates/preferences_form.php
@@ -0,0 +1,37 @@
+<?php
+use Digitigrade\UserSettings;
+
+$settings = new UserSettings($user);
+?>
+<form class="settings" method="post" action="/todo" hx-post="/fragment/preferences" hx-disabled-elt="find button"
+ hx-swap="outerHTML">
+
+ <?php
+ call_template('settings_field', [
+ 'id' => 'pref-requestToFollow',
+ 'fieldName' => 'requestToFollow',
+ 'name' => __('preferences.requestToFollow.name'),
+ 'description' => __('preferences.requestToFollow.description'),
+ 'value' => $user->actor->requestToFollow,
+ 'type' => 'boolean'
+ ]);
+ call_template('settings_field', [
+ 'id' => 'pref-smallIndicators',
+ 'fieldName' => 'smallIndicators',
+ 'name' => __('preferences.smallIndicators.name'),
+ 'description' => __('preferences.smallIndicators.description'),
+ 'value' => $settings->getBool('interface.smallUnreadIndicators'),
+ 'type' => 'boolean'
+ ]);
+ ?>
+
+ <div class="row">
+ <div>
+ <?php if ($saved ?? false): ?>
+ <span class="temporaryIndicator"><?= __('form.saved') ?></span>
+ <?php endif; ?>
+ </div>
+ <button class="primary"><?= __('form.saveChanges') ?></button>
+ </div>
+
+</form> \ No newline at end of file
diff --git a/templates/preferences_page.php b/templates/preferences_page.php
new file mode 100644
index 0000000..bcb39b6
--- /dev/null
+++ b/templates/preferences_page.php
@@ -0,0 +1,4 @@
+<?php call_template('skeleton', ['pageTitle' => __('preferences.pageTitle')], function () {
+ global $user, $saved;
+ call_template('preferences_form', ['user' => $user, 'saved' => $saved ?? false]);
+}); \ No newline at end of file
diff --git a/templates/settings_field.php b/templates/settings_field.php
index 1ebbfb3..fe68a52 100644
--- a/templates/settings_field.php
+++ b/templates/settings_field.php
@@ -1,13 +1,14 @@
<?php
-$id = "setting-$key";
+$id ??= "setting-$key";
$settingType = $type ?? 'text';
$inputType = $settingType;
if ($settingType == 'boolean') {
- $inputType = 'select';
- $options = ['true', 'false'];
+ $inputType = 'checkbox';
+ $options = ['false', 'true'];
+ $checked = $value == 'true' || $value == true;
}
// php converts . to _ in $_POST for some mikuforsaken reason so i'm working around it like this
-$fieldName = str_replace('.', '<DOT>', $key);
+$fieldName ??= str_replace('.', '<DOT>', $key);
?>
<div class="row">
@@ -24,6 +25,11 @@ $fieldName = str_replace('.', '<DOT>', $key);
<?php endforeach; ?>
</select>
+ <?php elseif ($inputType == 'checkbox'): ?>
+
+ <input type="hidden" name="<?= $fieldName ?>" value="<?= $options[0] ?>">
+ <input id="<?= $id ?>" name="<?= $fieldName ?>" value="<?= $options[1] ?>" type="checkbox" <?= $checked ? 'checked' : '' ?> autocomplete="off">
+
<?php elseif ($inputType == 'longtext'): ?>
<textarea id="<?= $id ?>" name="<?= $fieldName ?>" autocomplete="off"><?= $value ?></textarea>
diff --git a/templates/unread_indicator.php b/templates/unread_indicator.php
index bacacc3..ecfcb43 100644
--- a/templates/unread_indicator.php
+++ b/templates/unread_indicator.php
@@ -1 +1,11 @@
-<span class="unreadIndicator"><?= $count ?></span> \ No newline at end of file
+<?php
+use Digitigrade\Model\UserAccount;
+use Digitigrade\UserSettings;
+
+$user = UserAccount::findByCurrentSession();
+$small = false;
+if ($user != null) {
+ $small = (new UserSettings($user))->getBool('interface.smallUnreadIndicators') ?? false;
+}
+?>
+<span class="unreadIndicator <?= $small ? 'reduced' : '' ?>"><?= $count ?></span> \ No newline at end of file