From a5ffbec8d4b63953781decbddadab1e85a203ef1 Mon Sep 17 00:00:00 2001 From: Johnny Gear Date: Thu, 5 Mar 2026 13:46:44 -0600 Subject: [PATCH] Mastodon preferences --- db/models.py | 3 + db/queries.py | 9 ++ flask/api.py | 28 +++++- flask/vite/src/Profile.tsx | 85 ++++++++++++++++++- flask/vite/src/UserContext.tsx | 2 + flask/vite/src/main.tsx | 3 +- flask/vite/tsconfig.json | 2 +- .../016_add_user_mastodon_attn_public.py | 50 +++++++++++ 8 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 migrations/016_add_user_mastodon_attn_public.py diff --git a/db/models.py b/db/models.py index 10d8717..4f2551c 100644 --- a/db/models.py +++ b/db/models.py @@ -30,6 +30,9 @@ class User(BaseModel): mastodon_username = TextField(null=True) mastodon_access_token = TextField(null=True) + mastodon_attn_list = TextField(null=True) + mastodon_post_public = BooleanField(null=True, default=False) + class Meta: table_name = 'user' diff --git a/db/queries.py b/db/queries.py index 8416dba..53585f5 100644 --- a/db/queries.py +++ b/db/queries.py @@ -38,6 +38,15 @@ def user_mastodon_user_set(id, mastodon_username, mastodon_access_token): ) return q.execute() +def user_preferences_set(id, mastodon_post_public, mastodon_attn_list): + q = User.update( + mastodon_attn_list=mastodon_attn_list, + mastodon_post_public=mastodon_post_public + ).where( + User.id == id + ) + return q.execute() + def mastodon_server_get(name): return MastodonServer.get(name=name) diff --git a/flask/api.py b/flask/api.py index e3283d3..a74b286 100644 --- a/flask/api.py +++ b/flask/api.py @@ -1,11 +1,14 @@ +import re import requests from functools import wraps from flask import Blueprint, jsonify, abort, request from flask_login import login_required, current_user from db.models import database, OrdersPool, Order, OrderAddOn, MastodonServer -from db.queries import user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_mastodon_server_set +from db.queries import user_get, domsubusers_list, orders_pool_list, orders_pool, mastodon_server_get, mastodon_server_put, user_mastodon_server_set, user_preferences_set from settings import MASTODON_OAUTH_CLIENT_NAME, MASTODON_OAUTH_REDIRECT_URI, MASTODON_OAUTH_SCOPES, MASTODON_OAUTH_CLIENT_WEBSITE +RE_MASTODON_ATTN_LIST = re.compile(r'^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$') + api = Blueprint('api', __name__) @api.route("/me") @@ -17,9 +20,30 @@ def me(): "username": user.telegram_username, "telegram_photo_url": user.telegram_photo_url, "mastodon_server": user.mastodon_server.name, - "mastodon_username": user.mastodon_username + "mastodon_username": user.mastodon_username, + "mastodon_attn_list": user.mastodon_attn_list, + "mastodon_post_public": user.mastodon_post_public }) +@api.route("/profile", methods=["POST",]) +@login_required +def profile(): + user = current_user.db_user + + attn_list = request.json['attn_list'] if 'attn_list' in request.json else user.mastodon_attn_list + post_public = request.json['post_public'] if 'post_public' in request.json else user.mastodon_post_public + + if attn_list is not None and RE_MASTODON_ATTN_LIST.fullmatch(attn_list) is None: + abort(500) + + user_preferences_set( + user.id, + post_public, + attn_list + ) + + return ('', 204) + @api.route('/mastodon_oauth') @login_required def mastodon_oauth(): diff --git a/flask/vite/src/Profile.tsx b/flask/vite/src/Profile.tsx index 93b6881..6df6498 100644 --- a/flask/vite/src/Profile.tsx +++ b/flask/vite/src/Profile.tsx @@ -4,6 +4,8 @@ import { Avatar, Box, Button, + Checkbox, + Fieldset, Flex, Modal, Paper, @@ -11,8 +13,12 @@ import { Title, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { NavigateButton } from "./NavigateButton"; -import { Link } from "react-router"; +import { Link, useLoaderData } from "react-router"; +import { useForm } from "@mantine/form"; +import { notifications } from "@mantine/notifications"; +import { fetchHeaders } from "./fetch"; + +const RE_MASTODON_ACCOUNTS = /^(@(\w+)(@([\w\.]+))? )*(@\w+)(@[\w\.]+)?$/; export const AuthorizeMastodonModal: React.FC<{ opened: boolean; @@ -46,9 +52,21 @@ export const AuthorizeMastodonModal: React.FC<{ ); }; +type MastodonPreferencesForm = { + attn_list?: string; + post_public?: boolean; +}; + +export const profileLoader = async () => + fetch(`/api/me`).then((response) => response.json()); + export const Profile: React.FC = () => { const { username, telegram_photo_url, mastodon_server, mastodon_username } = useUserContext(); + const { mastodon_attn_list, mastodon_post_public } = useLoaderData<{ + mastodon_attn_list?: string; + mastodon_post_public?: boolean; + }>(); const [opened, { open, close }] = useDisclosure(false); const mastodon_account = React.useMemo( @@ -56,6 +74,51 @@ export const Profile: React.FC = () => { [mastodon_server, mastodon_username], ); + const [loading, setLoading] = React.useState(false); + const form = useForm({ + mode: "uncontrolled", + initialValues: { + attn_list: mastodon_attn_list, + post_public: mastodon_post_public, + }, + validate: { + attn_list: (value: string) => + !value || RE_MASTODON_ACCOUNTS.test(value) + ? null + : "Please enter a valid list of accounts", + }, + }); + + const handleSubmit = React.useCallback( + form.onSubmit((values) => { + setLoading(true); + fetch(`/api/profile`, { + method: "POST", + headers: fetchHeaders(), + body: JSON.stringify(values), + }) + .then((response) => { + if (response.ok) { + notifications.show({ + title: "Success", + message: "Your preferences have been saved", + color: "green", + }); + } else { + notifications.show({ + title: "Error", + message: "There was a problem saving your preferences", + color: "red", + }); + } + }) + .finally(() => { + setLoading(false); + }); + }), + [], + ); + return ( <> @@ -70,6 +133,24 @@ export const Profile: React.FC = () => { +
+
+ + + + +
); diff --git a/flask/vite/src/UserContext.tsx b/flask/vite/src/UserContext.tsx index feb581d..495f72a 100644 --- a/flask/vite/src/UserContext.tsx +++ b/flask/vite/src/UserContext.tsx @@ -5,6 +5,8 @@ export interface UserContextData { telegram_photo_url?: string; mastodon_server?: string; mastodon_username?: string; + mastodon_attn_list?: string; + mastodon_post_public?: boolean; } const UserContext = React.createContext({}); diff --git a/flask/vite/src/main.tsx b/flask/vite/src/main.tsx index 5964919..94a4a71 100644 --- a/flask/vite/src/main.tsx +++ b/flask/vite/src/main.tsx @@ -20,7 +20,7 @@ import { SubOrderSets, subOrderSetsLoader } from "./SubOrderSets"; import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet"; import { UserContextProvider } from "./UserContext"; import { Header } from "./Header"; -import { Profile } from "./Profile"; +import { Profile, profileLoader } from "./Profile"; const theme = createTheme({ components: { @@ -61,6 +61,7 @@ const router = createBrowserRouter([ { path: "profile", Component: Profile, + loader: profileLoader, }, { path: "orders/:username", diff --git a/flask/vite/tsconfig.json b/flask/vite/tsconfig.json index b7c9429..f15d521 100644 --- a/flask/vite/tsconfig.json +++ b/flask/vite/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "jsx": "react-jsx", - "lib": ["ES2015", "DOM"], + "lib": ["ES2018", "DOM"], "typeRoots": ["src/*.d.ts"], "esModuleInterop": true } diff --git a/migrations/016_add_user_mastodon_attn_public.py b/migrations/016_add_user_mastodon_attn_public.py new file mode 100644 index 0000000..ab09363 --- /dev/null +++ b/migrations/016_add_user_mastodon_attn_public.py @@ -0,0 +1,50 @@ +"""Peewee migrations -- 016_add_user_mastodon_attn_public.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + migrator.add_fields( + 'user', + + mastodon_attn_list=pw.TextField(null=True), + mastodon_post_public=pw.BooleanField(default=False, null=True)) + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_fields('user', 'mastodon_attn_list', 'mastodon_post_public')