Mastodon preferences

This commit is contained in:
Johnny Gear 2026-03-05 13:46:44 -06:00
parent 7b6e3700e4
commit a5ffbec8d4
8 changed files with 176 additions and 6 deletions

View file

@ -30,6 +30,9 @@ class User(BaseModel):
mastodon_username = TextField(null=True) mastodon_username = TextField(null=True)
mastodon_access_token = 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: class Meta:
table_name = 'user' table_name = 'user'

View file

@ -38,6 +38,15 @@ def user_mastodon_user_set(id, mastodon_username, mastodon_access_token):
) )
return q.execute() 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): def mastodon_server_get(name):
return MastodonServer.get(name=name) return MastodonServer.get(name=name)

View file

@ -1,11 +1,14 @@
import re
import requests import requests
from functools import wraps from functools import wraps
from flask import Blueprint, jsonify, abort, request from flask import Blueprint, jsonify, abort, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from db.models import database, OrdersPool, Order, OrderAddOn, MastodonServer 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 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 = Blueprint('api', __name__)
@api.route("/me") @api.route("/me")
@ -17,9 +20,30 @@ def me():
"username": user.telegram_username, "username": user.telegram_username,
"telegram_photo_url": user.telegram_photo_url, "telegram_photo_url": user.telegram_photo_url,
"mastodon_server": user.mastodon_server.name, "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') @api.route('/mastodon_oauth')
@login_required @login_required
def mastodon_oauth(): def mastodon_oauth():

View file

@ -4,6 +4,8 @@ import {
Avatar, Avatar,
Box, Box,
Button, Button,
Checkbox,
Fieldset,
Flex, Flex,
Modal, Modal,
Paper, Paper,
@ -11,8 +13,12 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { NavigateButton } from "./NavigateButton"; import { Link, useLoaderData } from "react-router";
import { Link } 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<{ export const AuthorizeMastodonModal: React.FC<{
opened: boolean; 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 = () => { export const Profile: React.FC = () => {
const { username, telegram_photo_url, mastodon_server, mastodon_username } = const { username, telegram_photo_url, mastodon_server, mastodon_username } =
useUserContext(); useUserContext();
const { mastodon_attn_list, mastodon_post_public } = useLoaderData<{
mastodon_attn_list?: string;
mastodon_post_public?: boolean;
}>();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const mastodon_account = React.useMemo( const mastodon_account = React.useMemo(
@ -56,6 +74,51 @@ export const Profile: React.FC = () => {
[mastodon_server, mastodon_username], [mastodon_server, mastodon_username],
); );
const [loading, setLoading] = React.useState(false);
const form = useForm<MastodonPreferencesForm>({
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 ( return (
<> <>
<Flex align="center" gap="md" mb="xl"> <Flex align="center" gap="md" mb="xl">
@ -70,6 +133,24 @@ export const Profile: React.FC = () => {
<TextInput label="Account" w="50%" value={mastodon_account} /> <TextInput label="Account" w="50%" value={mastodon_account} />
<Button onClick={open}>Authorize with Mastodon</Button> <Button onClick={open}>Authorize with Mastodon</Button>
<AuthorizeMastodonModal opened={opened} onClose={close} /> <AuthorizeMastodonModal opened={opened} onClose={close} />
<Fieldset legend="Preferences" my="lg">
<form onSubmit={handleSubmit}>
<Checkbox
{...form.getInputProps("post_public", { type: "checkbox" })}
label="Post orders publically"
mb="md"
/>
<TextInput
{...form.getInputProps("attn_list")}
label="Tag Accounts"
w="50%"
description="Additional accounts to tag with your orders"
/>
<Button type="submit" loading={loading} mt="md">
Save
</Button>
</form>
</Fieldset>
</Paper> </Paper>
</> </>
); );

View file

@ -5,6 +5,8 @@ export interface UserContextData {
telegram_photo_url?: string; telegram_photo_url?: string;
mastodon_server?: string; mastodon_server?: string;
mastodon_username?: string; mastodon_username?: string;
mastodon_attn_list?: string;
mastodon_post_public?: boolean;
} }
const UserContext = React.createContext<UserContextData>({}); const UserContext = React.createContext<UserContextData>({});

View file

@ -20,7 +20,7 @@ import { SubOrderSets, subOrderSetsLoader } from "./SubOrderSets";
import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet"; import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet";
import { UserContextProvider } from "./UserContext"; import { UserContextProvider } from "./UserContext";
import { Header } from "./Header"; import { Header } from "./Header";
import { Profile } from "./Profile"; import { Profile, profileLoader } from "./Profile";
const theme = createTheme({ const theme = createTheme({
components: { components: {
@ -61,6 +61,7 @@ const router = createBrowserRouter([
{ {
path: "profile", path: "profile",
Component: Profile, Component: Profile,
loader: profileLoader,
}, },
{ {
path: "orders/:username", path: "orders/:username",

View file

@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["ES2015", "DOM"], "lib": ["ES2018", "DOM"],
"typeRoots": ["src/*.d.ts"], "typeRoots": ["src/*.d.ts"],
"esModuleInterop": true "esModuleInterop": true
} }

View file

@ -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')