Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
S0yKaf 2025-01-18 13:55:15 -05:00
commit 0e137c0f2d
1759 changed files with 864109 additions and 314186 deletions

View file

@ -0,0 +1,10 @@
/*
theme-title: Blurple (auto)
theme-description: Official blurple theme that adapts to system preferences
*/
/* Default to dark theme */
@import url("blurple-dark.css");
@import url("blurple-light.css") screen and (prefers-color-scheme: light);
@import url("blurple-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -22,7 +22,7 @@
--blue3: var(--blurple3);
/* Basic page styling (background + foreground) */
--bg: linear-gradient(var(--blurple7), black);
--bg: var(--blurple7);
--bg-accent: var(--blurple6);
--fg: var(--blurple1);
--fg-reduced: var(--blurple2);
@ -44,6 +44,11 @@
--boxshadow-border: 0.08rem solid black;
}
/* Main page background */
body {
background: linear-gradient(var(--blurple7), black);
}
/* Scroll bar */
html, body {
scrollbar-color: var(--blurple4) var(--blurple7);

View file

@ -24,7 +24,7 @@
--blue3: var(--blurple6);
/* Basic page styling (background + foreground) */
--bg: linear-gradient(var(--blurple1), white);
--bg: var(--blurple1);
--bg-accent: var(--white2);
--fg: var(--gray1);
--fg-reduced: var(--gray2);
@ -46,6 +46,11 @@
--boxshadow-border: 0.08rem solid var(--blurple6);
}
/* Main page background */
body {
background: linear-gradient(var(--blurple1), white);
}
/* Scroll bar */
html, body {
scrollbar-color: var(--blurple5) var(--blurple2);

View file

@ -0,0 +1,10 @@
/*
theme-title: Brutalist (auto)
theme-description: Official (Pseudo-)monochrome brutality theme that adapts to system preferences
*/
/* Default to brutalist theme */
@import url("brutalist.css");
@import url("brutalist.css") screen and (prefers-color-scheme: light);
@import url("brutalist-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -66,11 +66,7 @@
--blue3: var(--ecks-pee-white);
/* Basic page styling (background + foreground) */
--bg: radial-gradient(
circle closest-corner at 20% 20%,
var(--ecks-pee-lighter-blue),
var(--ecks-pee-light-blue)
);
--bg: var(--ecks-pee-light-blue);
--bg-accent: var(--ecks-pee-blue);
--fg: var(--ecks-pee-white);
--fg-reduced: var(--ecks-pee-lightest-blue);
@ -122,6 +118,15 @@
src: url(/assets/fonts/Atkinson-Hyperlegible-BoldItalic-102.woff) format('woff');
}
/* Main page background */
body {
background: radial-gradient(
circle closest-corner at 20% 20%,
var(--ecks-pee-lighter-blue),
var(--ecks-pee-light-blue)
);
}
/* Scroll bar */
html, body {
/* Try Atkinson, fall back to default GtS fonts */

View file

@ -19,8 +19,14 @@
--blue3: var(--acid-green);
}
/* Main page background */
body {
background: linear-gradient(90deg, var(--darkmagenta), black, var(--darkmagenta));
background: linear-gradient(
90deg,
var(--darkmagenta),
black,
var(--darkmagenta)
);
}
html, body {

View file

@ -0,0 +1,162 @@
/*
theme-title: Moonlight Hunt
theme-description: Ominous dark blue / black with a tinge of blood red. You may think it all a mere bad dream.
*/
:root {
/* Define our palette */
--bleached-bone: #f3e3d4;
--void-blue: #0e131f;
--outer-space: #06080e;
--ghastly-blue: #88bebe;
--blood-red: #6c1619;
--bright-red: #f61a1ae6;
--feral-orange: #f78d17;
/* Restyle basic colors */
--white1: var(--void-blue);
--white2: var(--void-blue);
--orange2: var(--bright-red);
--blue1: var(--ghastly-blue);
--blue2: var(--ghastly-blue);
--blue3: var(--ghastly-blue);
/* Basic page styling (background + foreground) */
--bg: var(--void-blue);
--bg-accent: var(--void-blue);
--fg: var(--bleached-bone);
--fg-reduced: var(--bleached-bone);
--profile-bg: var(--void-blue);
/* Buttons */
--bloodshot: linear-gradient(
var(--bright-red),
var(--blood-red),
var(--blood-red),
var(--bright-red)
);
--button-bg: var(--bloodshot);
--button-fg: var(--bleached-bone);
/* Statuses */
--status-bg: var(--void-blue);
--status-focus-bg: var(--void-blue);
/* Used around statuses + other items */
--ghastly-border: 0.1rem solid var(--ghastly-blue);
--boxshadow-border: var(--ghastly-border);
}
/* Main page background */
body {
background: linear-gradient(
90deg,
var(--blood-red),
black 20%,
black 80%,
var(--blood-red)
);
}
/* Scroll bar */
html, body {
scrollbar-color: var(--bright-red) var(--outer-space);
text-shadow: 1px 1px var(--blood-red);
}
/* Instance title color */
.page-header a h1 {
color: var(--bleached-bone);
}
.profile .profile-header {
border: var(--ghastly-border);
}
.col-header {
border: var(--ghastly-border);
background: var(--outer-space);
}
.profile .about-user .col-header {
background: var(--void-blue);
border-bottom: none;
margin-bottom: 0;
}
/* Fiddle around with borders on about sections */
.profile .about-user .fields,
.profile .about-user .bio,
.profile .about-user .accountstats {
border-left: var(--ghastly-border);
border-right: var(--ghastly-border);
}
.profile .about-user .accountstats {
border-bottom: var(--ghastly-border);
background: var(--outer-space);
}
/* Role and bot badge backgrounds */
.profile .profile-header .basic-info .namerole .role,
.profile .profile-header .basic-info .namerole .bot-username-wrapper .bot-legend-wrapper {
background: var(--outer-space);
}
/* Status media */
.status .media .media-wrapper {
border: var(--ghastly-border);
}
.status .media .media-wrapper details .unknown-attachment .placeholder {
color: var(--bleached-bone);
}
.status .media .media-wrapper details video.plyr-video {
background: var(--outer-space);
}
/* Status polls */
.status .text .poll {
background-color: var(--outer-space);
border: var(--ghastly-border);
}
.status .text .poll .poll-info {
background-color: var(--void-blue);
}
/* Code snippets */
pre, pre[class*="language-"],
code, code[class*="language-"] {
background-color: var(--outer-space);
color: var(--bleached-bone);
}
/* Block quotes */
blockquote {
background-color: var(--outer-space);
color: var(--bleached-bone);
}
/* Status info bars */
.status .status-info,
.status.expanded .status-info {
color: var(--ghastly-blue);
border-top: 0.1rem dotted var(--ghastly-blue);
background: var(--outer-space);
}
/* Make show more/less buttons more legible */
.status .button {
border: 1px solid var(--feral-orange);
}
.status .button:hover {
border: 1px solid var(--bleached-bone);
background: var(--bloodshot);
}
/* Back + next links */
.profile .statuses .backnextlinks a {
color: var(--bleached-bone);
}
.page-footer nav ul li a {
color: var(--bleached-bone);
}

View file

@ -8,21 +8,21 @@
v1.0 by xmgz at github */
:root {
/* color definitions */
--dgreen1: #003333;
--dgreen2: #196C41;
--dgreen3: #027C68;
--dgreen4: #009933;
--dblue1: #141E46; /* very dark blue */
--typecolor: #F8F4EC;
--linkcolor: #c0f0c0; /* very soft lime green */
--sunny: #FCDC2A;
--lesssunny: #FF7431; /* papaya */
/* wood/earth colors */
--codebg: #3A2722; /* darker caoba */
--quotebg: #800000; /* maroon */
/* water, post's date and stats. User stats */
--fg-reduced: #BBEBFF;
/* color definitions */
--dgreen1: #003333;
--dgreen2: #196C41;
--dgreen3: #027C68;
--dgreen4: #009933;
--dblue1: #141E46; /* very dark blue */
--typecolor: #F8F4EC;
--linkcolor: #c0f0c0; /* very soft lime green */
--sunny: #FCDC2A;
--lesssunny: #FF7431; /* papaya */
/* wood/earth colors */
--codebg: #3A2722; /* darker caoba */
--quotebg: #800000; /* maroon */
/* water, post's date and stats. User stats */
--fg-reduced: #BBEBFF;
/* Restyle basic colors */
--blue1: var(--dgreen2);
@ -43,12 +43,9 @@
--button-bg: var(--lesssunny);
--button-fg: var(--dblue1);
/* Used around statuses + other items */
--boxshadow: 0 0.4rem 0.7rem -0.1rem rgba(252,220,42,0.15); /* subtle status glow */
--boxshadow-border: 0.07rem solid var(--lesssunny); /* thin papaya border */
}

View file

@ -20,7 +20,7 @@
--br-inner: 0.4rem;
/* Basic page styling (background + foreground) */
--bg: linear-gradient(-90deg, var(--soft-blue), var(--soft-pink), white, var(--soft-pink), var(--soft-blue));
--bg: var(--soft-pink);
--bg-accent: var(--soft-pink-translucent);
--fg: var(--gray1);
--fg-reduced: var(--gray3);
@ -41,6 +41,18 @@
--boxshadow-border: 0.08rem solid var(--gray8);
}
/* Main page background */
body {
background: linear-gradient(
-90deg,
var(--soft-blue),
var(--soft-pink),
white,
var(--soft-pink),
var(--soft-blue)
);
}
/* Scroll bar */
html, body {
scrollbar-color: var(--orange2) var(--soft-pink);

View file

@ -0,0 +1,10 @@
/*
theme-title: Solarized (auto)
theme-description: Solarized theme that adapts to system preferences
*/
/* Default to dark theme */
@import url("solarized-dark.css");
@import url("solarized-light.css") screen and (prefers-color-scheme: light);
@import url("solarized-dark.css") screen and (prefers-color-scheme: dark);

View file

@ -1,6 +1,6 @@
/*
theme-title: Solarized (dark)
theme-description: Solarized sloth (dark)
theme-title: Solarized dark
theme-description: Dark green and grey solarized theme with red trim
*/
/*
@ -29,57 +29,42 @@
:root {
/* Define solarized palette */
--base3: #002b36;
--base2: #073642;
--base1: #586e75;
--base0: #657b83;
--base00: #839496;
--base01: #93a1a1;
--base02: #eee8d5;
--base03: #fdf6e3;
--yellow: #b58900;
--orange: #cb4b16;
--red: #dc322f;
--magenta: #d33682;
--violet: #6c71c4;
--blue: #268bd2;
--cyan: #2aa198;
--green: #859900;
--base03: #002b36; /* Background. */
--base02: #073642; /* Background highlights. */
--base01: #586e75; /* Comments / secondary color. */
--base0: #839496; /* Body text / default code / primary content. */
--base1: #93a1a1; /* Optional emphasized content. */
--red: #dc322f; /* Trim. */
/* Override orange trim */
--orange2: var(--red);
/* Restyle basic colors to use Solarized */
--white1: var(--base3);
--white2: var(--base2);
--blue1: var(--cyan);
--blue2: var(--base03);
--blue3: var(--base02);
--white1: var(--base03);
--white2: var(--base02);
--blue2: var(--base1);
--blue3: var(--base1);
/* Basic page styling (background + foreground) */
--bg: var(--white1);
--bg-accent: var(--white2);
--fg: var(--base02);
--fg-reduced: var(--base01);
--bg: var(--base02);
--bg-accent: var(--base02);
--fg-reduced: var(--base0);
--fg: var(--base0);
/* Profile page styling (light) */
/* Profile page styling */
--profile-bg: var(--white2);
/* Solarize buttons */
--button-bg: var(--blue2);
--button-fg: var(--white1);
/* Solarize statuses */
--status-bg: var(--white1);
--status-focus-bg: var(--white1);
--status-info-bg: var(--white2);
--status-focus-info-bg: var(--white2);
--status-bg: var(--base03);
--status-focus-bg: var(--base03);
--status-info-bg: var(--base02);
--status-focus-info-bg: var(--base02);
/* Used around statuses + other items */
--boxshadow-border: 0.1rem solid var(--base1);
--boxshadow-border: 0.15rem solid var(--base01);
--plyr-video-control-color: var(--fg-reduced);
--plyr-video-control-color-hover: var(--fg);
--plyr-video-control-color: var(--base1);
--plyr-video-control-color-hover: var(--base03);
}
@font-face {
@ -95,6 +80,45 @@ html, body {
scrollbar-color: var(--orange2) var(--white1) ;
}
/* Column headers */
.col-header {
border: var(--boxshadow-border);
color: var(--base1);
}
.profile .about-user .col-header {
border-bottom: none;
margin-bottom: 0;
}
/* Instance title color */
.page-header a h1 {
color: var(--base1);
}
/* Header card */
.profile .profile-header {
border: var(--boxshadow-border);
background: var(--base03);
}
/* Fiddle around with borders on about sections */
.profile .about-user .fields,
.profile .about-user .bio,
.profile .about-user .accountstats {
background: var(--base03);
border-left: var(--boxshadow-border);
border-right: var(--boxshadow-border);
}
.profile .about-user .accountstats {
border-bottom: var(--boxshadow-border);
color: var(--base0);
}
.profile .about-user .fields {
padding-top: 0;
}
/* Profile fields */
.profile .about-user .fields .field {
border-bottom: var(--boxshadow-border);
@ -106,9 +130,11 @@ html, body {
/* Status media */
.status .media .media-wrapper {
border: var(--boxshadow-border);
background: var(--base02);
}
.status .media .media-wrapper details .unknown-attachment .placeholder {
color: var(--blue2);
color: var(--base1);
border: 0.2rem dashed var(--base1);
}
.status .media .media-wrapper details video.plyr-video {
background: transparent;
@ -116,32 +142,50 @@ html, body {
/* Status polls */
.status .text .poll {
background-color: var(--white2);
background-color: var(--base03);
border: var(--boxshadow-border);
}
.status .text .poll .poll-info {
background-color: var(--white1);
background-color: var(--base03);
}
/* Code snippets */
pre, pre[class*="language-"],
code, code[class*="language-"] {
background-color: black;
color: var(--base03);
color: #93a1a1;
}
/* Block quotes */
blockquote {
background-color: var(--bg-accent);
color: var(--fg);
background-color: var(--base03);
color: var(--base0);
}
button,
.button,
button, .button,
.status .text-spoiler > summary .button {
font-family: 'Noto Sans Mono', monospace;
}
.button {
color: var(--base1);
background: var(--base02);
border: var(--boxshadow-border);
}
.button:hover {
color: var(--base0);
background: var(--base03);
border: var(--boxshadow-border);
}
/* Ensure role badge readable */
.profile .profile-header .basic-info .namerole .role.admin {
color: var(--base03);
color: var(--base1);
}
/* Distinguish bot icon */
.profile .profile-header .basic-info .namerole .bot-username-wrapper .bot-legend-wrapper {
border: var(--boxshadow-border);
color: var(--base1);
}

View file

@ -1,6 +1,6 @@
/*
theme-title: Solarized (light)
theme-description: Solarized sloth (light)
theme-title: Solarized light
theme-description: Beige and grey solarized theme with orange trim
*/
/*
@ -29,46 +29,31 @@
:root {
/* Define solarized palette */
--base03: #002b36;
--base02: #073642;
--base01: #586e75;
--base00: #657b83;
--base0: #839496;
--base1: #93a1a1;
--base2: #eee8d5;
--base3: #fdf6e3;
--yellow: #b58900;
--orange: #cb4b16;
--red: #dc322f;
--magenta: #d33682;
--violet: #6c71c4;
--blue: #268bd2;
--cyan: #2aa198;
--green: #859900;
--base3: #fdf6e3; /* Background. */
--base2: #eee8d5; /* Background highlights. */
--base1: #93a1a1; /* Comments / secondary color. */
--base00: #657b83; /* Body text / default code / primary content. */
--base01: #586e75; /* Optional emphasized content. */
--red: #cb4b16; /* Trim. */
/* Override orange trim */
--orange2: var(--orange);
--orange2: var(--red);
/* Restyle basic colors to use Solarized */
--white1: var(--base3);
--white2: var(--base2);
--blue1: var(--cyan);
--blue2: var(--base03);
--blue3: var(--base02);
--blue2: var(--base00);
--blue3: var(--base01);
/* Basic page styling (background + foreground) */
--bg: var(--white1);
--bg-accent: var(--white2);
--fg: var(--base02);
--fg-reduced: var(--base01);
--bg: var(--base2);
--bg-accent: var(--base3);
--fg-reduced: var(--base00);
--fg: var(--base01);
/* Profile page styling (light) */
/* Profile page styling */
--profile-bg: var(--white2);
/* Solarize buttons */
--button-bg: var(--blue2);
--button-fg: var(--white1);
/* Solarize statuses */
--status-bg: var(--white1);
--status-focus-bg: var(--white1);
@ -78,8 +63,8 @@
/* Used around statuses + other items */
--boxshadow-border: 0.1rem solid var(--base1);
--plyr-video-control-color: var(--fg-reduced);
--plyr-video-control-color-hover: var(--fg);
--plyr-video-control-color: var(--fg);
--plyr-video-control-color-hover: var(--fg-reduced);
}
@font-face {
@ -95,6 +80,36 @@ html, body {
scrollbar-color: var(--orange2) var(--white1) ;
}
/* Column headers */
.col-header {
border: var(--boxshadow-border);
background: var(--base3);
}
.profile .about-user .col-header {
border-bottom: none;
margin-bottom: 0;
}
/* Header card */
.profile .profile-header {
border: var(--boxshadow-border);
background: var(--base3);
}
/* Fiddle around with borders on about sections */
.profile .about-user .fields,
.profile .about-user .bio,
.profile .about-user .accountstats {
background: var(--base3);
color: var(--base01);
border-left: var(--boxshadow-border);
border-right: var(--boxshadow-border);
}
.profile .about-user .accountstats {
border-bottom: var(--boxshadow-border);
}
/* Profile fields */
.profile .about-user .fields .field {
border-bottom: var(--boxshadow-border);
@ -108,7 +123,8 @@ html, body {
border: var(--boxshadow-border);
}
.status .media .media-wrapper details .unknown-attachment .placeholder {
color: var(--blue2);
color: var(--base1);
border: 0.2rem dashed var(--base1);
}
.status .media .media-wrapper details video.plyr-video {
background: transparent;
@ -116,32 +132,60 @@ html, body {
/* Status polls */
.status .text .poll {
background-color: var(--white2);
background-color: var(--base3);
border: var(--boxshadow-border);
}
.status .text .poll .poll-info {
background-color: var(--white1);
background-color: var(--base3);
border: var(--boxshadow-border);
}
/* Code snippets */
pre, pre[class*="language-"],
code, code[class*="language-"] {
background-color: black;
color: var(--base3);
color: #93a1a1;
}
/* Block quotes */
blockquote {
background-color: var(--bg-accent);
color: var(--fg);
background-color: var(--base3);
color: var(--base01);
}
button,
.button,
button, .button,
.status .text-spoiler > summary .button {
font-family: 'Noto Sans Mono', monospace;
}
.button {
color: var(--base01);
background: var(--base3);
border: var(--boxshadow-border);
}
.button:hover {
color: var(--base01);
background: var(--base2);
border: var(--boxshadow-border);
}
/* Ensure role badge readable */
.profile .profile-header .basic-info .namerole .role.admin {
color: var(--base03);
background: var(--base3);
color: var(--base01);
}
/* Back + next links */
.backnextlinks {
padding: 0.5rem;
background: var(--base3);
border: var(--boxshadow-border);
border-radius: var(--br);
}
.page-footer {
margin-top: 2rem;
background-color: var(--base3);
border-top: var(--boxshadow-border);
}

View file

@ -23,7 +23,7 @@
--orange2: var(--pink);
/* Basic page styling (background + foreground) */
--bg: linear-gradient(var(--eggplant1), var(--pink), var(--orange), var(--yellow), var(--eggshell));
--bg: var(--eggshell);
--bg-accent: var(--white2);
--fg: var(--eggplant4);
--fg-reduced: var(--eggplant3);
@ -45,6 +45,17 @@
--boxshadow-border: 0.08rem solid var(--orange);
}
/* Main page background */
body {
background: linear-gradient(
var(--eggplant1),
var(--pink),
var(--orange),
var(--yellow),
var(--eggshell)
);
}
/* Scroll bar */
html, body {
scrollbar-color: var(--pink) var(--eggshell);
@ -55,12 +66,6 @@ html, body {
color: var(--eggshell);
}
/* Role and bot badge backgrounds */
.profile .profile-header .basic-info .namerole .role,
.profile .profile-header .basic-info .namerole .bot-username-wrapper .bot-legend-wrapper {
background: var(--eggshell);
}
/* Profile fields */
.profile .about-user .fields .field {
border-bottom: 0.1rem solid var(--orange);

View file

@ -20,7 +20,7 @@
"langs": "^2.0.0",
"match-sorter": "^6.3.1",
"modern-normalize": "^1.1.0",
"nanoid": "^4.0.0",
"nanoid": "^5.0.9",
"object-to-formdata": "^4.4.2",
"papaparse": "^5.3.2",
"parse-link-header": "^2.0.0",

View file

@ -107,7 +107,11 @@ function Error({ error, reset }: ErrorProps) {
{ reset &&
<span
className="dismiss"
onClick={reset}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
reset();
}}
role="button"
tabIndex={0}
>

View file

@ -26,6 +26,7 @@ import type {
import type {
FileFormInputHook,
NumberFormInputHook,
RadioFormInputHook,
TextFormInputHook,
} from "../../lib/form/types";
@ -57,6 +58,32 @@ export function TextInput({label, field, ...props}: TextInputProps) {
);
}
export interface NumberInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label?: ReactNode;
field: NumberFormInputHook;
}
export function NumberInput({label, field, ...props}: NumberInputProps) {
const { onChange, value, ref } = field;
return (
<div className={`form-field number${field.valid ? "" : " invalid"}`}>
<label>
{label}
<input
onChange={onChange}
value={value}
ref={ref as RefObject<HTMLInputElement>}
{...props}
/>
</label>
</div>
);
}
export interface TextAreaProps extends React.DetailedHTMLProps<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement

View file

@ -30,7 +30,7 @@ export interface PageableListProps<T> {
items?: T[];
itemToEntry: (_item: T) => ReactNode;
isLoading: boolean;
isFetching: boolean;
isFetching?: boolean;
isError: boolean;
error: FetchBaseQueryError | SerializedError | undefined;
emptyMessage: ReactNode;

View file

@ -19,6 +19,7 @@
import React from "react";
import Loading from "./loading";
import { Error as ErrorC } from "./error";
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/oauth";
import { useInstanceV1Query } from "../lib/query/gts-api";
@ -29,16 +30,20 @@ export default function UserLogoutCard() {
if (isLoading) {
return <Loading />;
} else {
return (
<div className="account-card">
<img className="avatar" src={profile.avatar} alt="" />
<h3 className="text-cutoff">{profile.display_name?.length > 0 ? profile.display_name : profile.acct}</h3>
<span className="text-cutoff">@{profile.username}@{instance?.account_domain}</span>
<a onClick={logoutQuery} href="#" aria-label="Log out" title="Log out" className="logout">
<i className="fa fa-fw fa-sign-out" aria-hidden="true" />
</a>
</div>
);
}
if (!profile) {
return <ErrorC error={new Error("account was undefined")} />;
}
return (
<div className="account-card">
<img className="avatar" src={profile.avatar} alt="" />
<h3 className="text-cutoff">{profile.display_name?.length > 0 ? profile.display_name : profile.acct}</h3>
<span className="text-cutoff">@{profile.username}@{instance?.account_domain}</span>
<a onClick={logoutQuery} href="#" aria-label="Log out" title="Log out" className="logout">
<i className="fa fa-fw fa-sign-out" aria-hidden="true" />
</a>
</div>
);
}

View file

@ -17,18 +17,107 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import React, { useEffect } from "react";
import { useLocation } from "wouter";
import { AdminAccount } from "../lib/types/account";
import { useLazyGetAccountQuery } from "../lib/query/admin";
import Loading from "./loading";
import { Error as ErrorC } from "./error";
interface UsernameProps {
interface UsernameLozengeProps {
/**
* Either an account ID (for fetching) or an account.
*/
account?: string | AdminAccount;
/**
* Make the lozenge clickable and link to this location.
*/
linkTo?: string;
/**
* Location to set as backLocation after linking to linkTo.
*/
backLocation?: string;
/**
* Additional classnames to add to the lozenge.
*/
classNames?: string[];
}
export default function UsernameLozenge({ account, linkTo, backLocation, classNames }: UsernameLozengeProps) {
if (account === undefined) {
return <>[unknown]</>;
} else if (typeof account === "string") {
return (
<FetchUsernameLozenge
accountID={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
} else {
return (
<ReadyUsernameLozenge
account={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
}
}
interface FetchUsernameLozengeProps {
accountID: string;
linkTo?: string;
backLocation?: string;
classNames?: string[];
}
function FetchUsernameLozenge({ accountID, linkTo, backLocation, classNames }: FetchUsernameLozengeProps) {
const [ trigger, result ] = useLazyGetAccountQuery();
// Call to get the account
// using the provided ID.
useEffect(() => {
trigger(accountID, true);
}, [trigger, accountID]);
const {
data: account,
isLoading,
isFetching,
isError,
error,
} = result;
// Wait for the account
// model to be returned.
if (isError) {
return <ErrorC error={error} />;
} else if (isLoading || isFetching || account === undefined) {
return <Loading />;
}
return (
<ReadyUsernameLozenge
account={account}
linkTo={linkTo}
backLocation={backLocation}
classNames={classNames}
/>
);
}
interface ReadyUsernameLozengeProps {
account: AdminAccount;
linkTo?: string;
backLocation?: string;
classNames?: string[];
}
export default function Username({ account, linkTo, backLocation, classNames }: UsernameProps) {
function ReadyUsernameLozenge({ account, linkTo, backLocation, classNames }: ReadyUsernameLozengeProps) {
const [ _location, setLocation ] = useLocation();
let className = "username-lozenge";

View file

@ -41,6 +41,7 @@ import type {
ChecklistInputHook,
FieldArrayInputHook,
ArrayInputHook,
NumberFormInputHook,
} from "./types";
function capitalizeFirst(str: string) {
@ -102,11 +103,11 @@ function value<T>(name: string, initialValue: T) {
name,
Name: "",
value: initialValue,
hasChanged: () => true, // always included
};
}
export const useTextInput = inputHook(text) as (_name: string, _opts?: HookOpts<string>) => TextFormInputHook;
export const useNumberInput = inputHook(text) as (_name: string, _opts?: HookOpts<number>) => NumberFormInputHook;
export const useFileInput = inputHook(file) as (_name: string, _opts?: HookOpts<File>) => FileFormInputHook;
export const useBoolInput = inputHook(bool) as (_name: string, _opts?: HookOpts<boolean>) => BoolFormInputHook;
export const useRadioInput = inputHook(radio) as (_name: string, _opts?: HookOpts<string>) => RadioFormInputHook;

View file

@ -0,0 +1,104 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {
useState,
useRef,
useTransition,
useEffect,
} from "react";
import type {
CreateHookNames,
HookOpts,
NumberFormInputHook,
} from "./types";
const _default = 0;
export default function useNumberInput(
{ name, Name }: CreateHookNames,
{
initialValue = _default,
dontReset = false,
validator,
showValidation = true,
initValidation,
nosubmit = false,
}: HookOpts<number>
): NumberFormInputHook {
const [number, setNumber] = useState(initialValue);
const numberRef = useRef<HTMLInputElement>(null);
const [validation, setValidation] = useState(initValidation ?? "");
const [_isValidating, startValidation] = useTransition();
const valid = validation == "";
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const input = e.target.valueAsNumber;
setNumber(input);
if (validator) {
startValidation(() => {
setValidation(validator(input));
});
}
}
function reset() {
if (!dontReset) {
setNumber(initialValue);
}
}
useEffect(() => {
if (validator && numberRef.current) {
if (showValidation) {
numberRef.current.setCustomValidity(validation);
} else {
numberRef.current.setCustomValidity("");
}
}
}, [validation, validator, showValidation]);
// Array / Object hybrid, for easier access in different contexts
return Object.assign([
onChange,
reset,
{
[name]: number,
[`${name}Ref`]: numberRef,
[`set${Name}`]: setNumber,
[`${name}Valid`]: valid,
}
], {
onChange,
reset,
name,
Name: "", // Will be set by inputHook function.
nosubmit,
value: number,
ref: numberRef,
setter: setNumber,
valid,
validate: () => setValidation(validator ? validator(number): ""),
hasChanged: () => number != initialValue,
_default
});
}

View file

@ -34,8 +34,24 @@ import type {
} from "./types";
interface UseFormSubmitOptions {
/**
* Include only changed fields when submitting the form.
* If no fields have been changed, submit will be a noop.
*/
changedOnly: boolean;
/**
* Optional function to run when the form has been sent
* and a response has been returned from the server.
*/
onFinish?: ((_res: any) => void);
/**
* Can be optionally used to modify the final mutation argument from the
* gathered mutation data before it's passed into the trigger function.
*
* Useful if the mutation trigger function takes not just a simple key/value
* object but a more complicated object.
*/
customizeMutationArgs?: (_mutationData: { [k: string]: any }) => any;
}
/**
@ -105,7 +121,7 @@ export default function useFormSubmit(
usedAction.current = action;
// Transform the hooked form into an object.
const {
let {
mutationData,
updatedFields,
} = getFormMutations(form, { changedOnly });
@ -117,7 +133,12 @@ export default function useFormSubmit(
return;
}
// Final tweaks on the mutation
// argument before triggering it.
mutationData.action = action;
if (opts.customizeMutationArgs) {
mutationData = opts.customizeMutationArgs(mutationData);
}
try {
const res = await runMutation(mutationData);

View file

@ -181,6 +181,13 @@ export interface TextFormInputHook extends FormInputHook<string>,
_withValidate,
_withRef {}
export interface NumberFormInputHook extends FormInputHook<number>,
_withSetter<number>,
_withOnChange,
_withReset,
_withValidate,
_withRef {}
export interface RadioFormInputHook extends FormInputHook<string>,
_withSetter<string>,
_withOnChange,

View file

@ -110,12 +110,19 @@ export function MenuItem(props: PropsWithChildren<MenuItemProps>) {
if (topLevel) {
classNames.push("category", "top-level");
} else {
if (thisLevel === 1 && hasChildren) {
classNames.push("category", "expanding");
} else if (thisLevel === 1 && !hasChildren) {
classNames.push("view", "expanding");
} else if (thisLevel === 2) {
classNames.push("view", "nested");
switch (true) {
case thisLevel === 1 && hasChildren:
classNames.push("category", "expanding");
break;
case thisLevel === 1 && !hasChildren:
classNames.push("view", "expanding");
break;
case thisLevel >= 2 && hasChildren:
classNames.push("nested", "category");
break;
case thisLevel >= 2 && !hasChildren:
classNames.push("nested", "view");
break;
}
}

View file

@ -0,0 +1,173 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { gtsApi } from "../../gts-api";
import type {
DomainPerm,
DomainPermDraftCreateParams,
DomainPermDraftSearchParams,
DomainPermDraftSearchResp,
} from "../../../types/domain-permission";
import parse from "parse-link-header";
import { PermType } from "../../../types/perm";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchDomainPermissionDrafts: build.query<DomainPermDraftSearchResp, DomainPermDraftSearchParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/admin/domain_permission_drafts${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: DomainPerm[], meta) => {
const drafts = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { drafts, links };
},
// Only provide TRANSFORMED tag id since this model is not the same
// as getDomainPermissionDraft model (due to transformResponse).
providesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }]
}),
getDomainPermissionDraft: build.query<DomainPerm, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_drafts/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionDraft', id }
],
}),
createDomainPermissionDraft: build.mutation<DomainPerm, DomainPermDraftCreateParams>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts`,
asForm: true,
body: formData,
discardEmpty: true
}),
invalidatesTags: [{ type: "DomainPermissionDraft", id: "TRANSFORMED" }],
}),
acceptDomainPermissionDraft: build.mutation<DomainPerm, { id: string, overwrite?: boolean, permType: PermType }>({
query: ({ id, overwrite }) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts/${id}/accept`,
asForm: true,
body: {
overwrite: overwrite,
},
discardEmpty: true
}),
invalidatesTags: (res, _error, { id, permType }) => {
const invalidated: any[] = [];
// If error, nothing to invalidate.
if (!res) {
return invalidated;
}
// Invalidate this draft by ID, and
// the transformed list of all drafts.
invalidated.push(
{ type: 'DomainPermissionDraft', id: id },
{ type: "DomainPermissionDraft", id: "TRANSFORMED" },
);
// Invalidate cached blocks/allows depending
// on the permType of the accepted draft.
if (permType === "allow") {
invalidated.push("domainAllows");
} else {
invalidated.push("domainBlocks");
}
return invalidated;
}
}),
removeDomainPermissionDraft: build.mutation<DomainPerm, { id: string, exclude_target?: boolean }>({
query: ({ id, exclude_target }) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_drafts/${id}/remove`,
asForm: true,
body: {
exclude_target: exclude_target,
},
discardEmpty: true
}),
invalidatesTags: (res, _error, { id }) =>
res
? [
{ type: "DomainPermissionDraft", id },
{ type: "DomainPermissionDraft", id: "TRANSFORMED" },
]
: [],
})
}),
});
/**
* View domain permission drafts.
*/
const useLazySearchDomainPermissionDraftsQuery = extended.useLazySearchDomainPermissionDraftsQuery;
/**
* Get domain permission draft with the given ID.
*/
const useGetDomainPermissionDraftQuery = extended.useGetDomainPermissionDraftQuery;
/**
* Create a domain permission draft with the given parameters.
*/
const useCreateDomainPermissionDraftMutation = extended.useCreateDomainPermissionDraftMutation;
/**
* Accept a domain permission draft, turning it into an enforced domain permission.
*/
const useAcceptDomainPermissionDraftMutation = extended.useAcceptDomainPermissionDraftMutation;
/**
* Remove a domain permission draft, optionally ignoring all future drafts targeting the given domain.
*/
const useRemoveDomainPermissionDraftMutation = extended.useRemoveDomainPermissionDraftMutation;
export {
useLazySearchDomainPermissionDraftsQuery,
useGetDomainPermissionDraftQuery,
useCreateDomainPermissionDraftMutation,
useAcceptDomainPermissionDraftMutation,
useRemoveDomainPermissionDraftMutation,
};

View file

@ -0,0 +1,124 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { gtsApi } from "../../gts-api";
import type {
DomainPerm,
DomainPermExcludeCreateParams,
DomainPermExcludeSearchParams,
DomainPermExcludeSearchResp,
} from "../../../types/domain-permission";
import parse from "parse-link-header";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchDomainPermissionExcludes: build.query<DomainPermExcludeSearchResp, DomainPermExcludeSearchParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/admin/domain_permission_excludes${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: DomainPerm[], meta) => {
const excludes = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { excludes, links };
},
// Only provide TRANSFORMED tag id since this model is not the same
// as getDomainPermissionExclude model (due to transformResponse).
providesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }]
}),
getDomainPermissionExclude: build.query<DomainPerm, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_excludes/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionExclude', id }
],
}),
createDomainPermissionExclude: build.mutation<DomainPerm, DomainPermExcludeCreateParams>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_excludes`,
asForm: true,
body: formData,
discardEmpty: true
}),
invalidatesTags: [{ type: "DomainPermissionExclude", id: "TRANSFORMED" }],
}),
deleteDomainPermissionExclude: build.mutation<DomainPerm, string>({
query: (id) => ({
method: "DELETE",
url: `/api/v1/admin/domain_permission_excludes/${id}`,
}),
invalidatesTags: (res, _error, id) =>
res
? [
{ type: "DomainPermissionExclude", id },
{ type: "DomainPermissionExclude", id: "TRANSFORMED" },
]
: [],
})
}),
});
/**
* View domain permission excludes.
*/
const useLazySearchDomainPermissionExcludesQuery = extended.useLazySearchDomainPermissionExcludesQuery;
/**
* Get domain permission exclude with the given ID.
*/
const useGetDomainPermissionExcludeQuery = extended.useGetDomainPermissionExcludeQuery;
/**
* Create a domain permission exclude with the given parameters.
*/
const useCreateDomainPermissionExcludeMutation = extended.useCreateDomainPermissionExcludeMutation;
/**
* Delete a domain permission exclude.
*/
const useDeleteDomainPermissionExcludeMutation = extended.useDeleteDomainPermissionExcludeMutation;
export {
useLazySearchDomainPermissionExcludesQuery,
useGetDomainPermissionExcludeQuery,
useCreateDomainPermissionExcludeMutation,
useDeleteDomainPermissionExcludeMutation,
};

View file

@ -37,6 +37,12 @@ const extended = gtsApi.injectEndpoints({
}),
transformResponse: listToKeyedObject<DomainPerm>("domain"),
}),
domainPermissionDrafts: build.query<any, void>({
query: () => ({
url: `/api/v1/admin/domain_permission_drafts`
}),
}),
}),
});

View file

@ -24,7 +24,7 @@ import {
type DomainPerm,
type ImportDomainPermsParams,
type MappedDomainPerms,
isDomainPermInternalKey,
stripOnImport,
} from "../../../types/domain-permission";
import { listToKeyedObject } from "../../transforms";
@ -83,7 +83,7 @@ function importEntriesProcessor(formData: ImportDomainPermsParams): (_entry: Dom
// Unset all internal processing keys
// and any undefined keys on this entry.
Object.entries(entry).forEach(([key, val]: [keyof DomainPerm, any]) => {
if (val == undefined || isDomainPermInternalKey(key)) {
if (val == undefined || stripOnImport(key)) {
delete entry[key];
}
});

View file

@ -0,0 +1,178 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { gtsApi } from "../../gts-api";
import type {
DomainPerm,
DomainPermSub,
DomainPermSubCreateUpdateParams,
DomainPermSubSearchParams,
DomainPermSubSearchResp,
} from "../../../types/domain-permission";
import parse from "parse-link-header";
import { PermType } from "../../../types/perm";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
searchDomainPermissionSubscriptions: build.query<DomainPermSubSearchResp, DomainPermSubSearchParams>({
query: (form) => {
const params = new(URLSearchParams);
Object.entries(form).forEach(([k, v]) => {
if (v !== undefined) {
params.append(k, v);
}
});
let query = "";
if (params.size !== 0) {
query = `?${params.toString()}`;
}
return {
url: `/api/v1/admin/domain_permission_subscriptions${query}`
};
},
// Headers required for paging.
transformResponse: (apiResp: DomainPermSub[], meta) => {
const subs = apiResp;
const linksStr = meta?.response?.headers.get("Link");
const links = parse(linksStr);
return { subs, links };
},
// Only provide TRANSFORMED tag id since this model is not the same
// as getDomainPermissionSubscription model (due to transformResponse).
providesTags: [{ type: "DomainPermissionSubscription", id: "TRANSFORMED" }]
}),
getDomainPermissionSubscriptionsPreview: build.query<DomainPermSub[], PermType>({
query: (permType) => ({
url: `/api/v1/admin/domain_permission_subscriptions/preview?permission_type=${permType}`
}),
providesTags: (_result, _error, permType) =>
// Cache by permission type.
[{ type: "DomainPermissionSubscription", id: `${permType}sByPriority` }]
}),
getDomainPermissionSubscription: build.query<DomainPermSub, string>({
query: (id) => ({
url: `/api/v1/admin/domain_permission_subscriptions/${id}`
}),
providesTags: (_result, _error, id) => [
{ type: 'DomainPermissionSubscription', id }
],
}),
createDomainPermissionSubscription: build.mutation<DomainPermSub, DomainPermSubCreateUpdateParams>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_subscriptions`,
asForm: true,
body: formData,
discardEmpty: true
}),
invalidatesTags: (_res, _error, formData) =>
[
// Invalidate transformed list of all perm subs.
{ type: "DomainPermissionSubscription", id: "TRANSFORMED" },
// Invalidate perm subs of this type sorted by priority.
{ type: "DomainPermissionSubscription", id: `${formData.permission_type}sByPriority` }
]
}),
updateDomainPermissionSubscription: build.mutation<DomainPermSub, { id: string, permType: PermType, formData: DomainPermSubCreateUpdateParams }>({
query: ({ id, formData }) => ({
method: "PATCH",
url: `/api/v1/admin/domain_permission_subscriptions/${id}`,
asForm: true,
body: formData,
}),
invalidatesTags: (_res, _error, { id, permType }) =>
[
// Invalidate this perm sub.
{ type: "DomainPermissionSubscription", id: id },
// Invalidate transformed list of all perms subs.
{ type: "DomainPermissionSubscription", id: "TRANSFORMED" },
// Invalidate perm subs of this type sorted by priority.
{ type: "DomainPermissionSubscription", id: `${permType}sByPriority` }
],
}),
removeDomainPermissionSubscription: build.mutation<DomainPermSub, { id: string, remove_children: boolean }>({
query: ({ id, remove_children }) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_subscriptions/${id}/remove`,
asForm: true,
body: { remove_children: remove_children },
}),
}),
testDomainPermissionSubscription: build.mutation<{ error: string } | DomainPerm[], string>({
query: (id) => ({
method: "POST",
url: `/api/v1/admin/domain_permission_subscriptions/${id}/test`,
}),
})
}),
});
/**
* View domain permission subscriptions.
*/
const useLazySearchDomainPermissionSubscriptionsQuery = extended.useLazySearchDomainPermissionSubscriptionsQuery;
/**
* Get domain permission subscription with the given ID.
*/
const useGetDomainPermissionSubscriptionQuery = extended.useGetDomainPermissionSubscriptionQuery;
/**
* Create a domain permission subscription with the given parameters.
*/
const useCreateDomainPermissionSubscriptionMutation = extended.useCreateDomainPermissionSubscriptionMutation;
/**
* View domain permission subscriptions of selected perm type, sorted by priority descending.
*/
const useGetDomainPermissionSubscriptionsPreviewQuery = extended.useGetDomainPermissionSubscriptionsPreviewQuery;
/**
* Update domain permission subscription.
*/
const useUpdateDomainPermissionSubscriptionMutation = extended.useUpdateDomainPermissionSubscriptionMutation;
/**
* Remove a domain permission subscription and optionally its children (harsh).
*/
const useRemoveDomainPermissionSubscriptionMutation = extended.useRemoveDomainPermissionSubscriptionMutation;
/**
* Test a domain permission subscription to see if data can be fetched + parsed.
*/
const useTestDomainPermissionSubscriptionMutation = extended.useTestDomainPermissionSubscriptionMutation;
export {
useLazySearchDomainPermissionSubscriptionsQuery,
useGetDomainPermissionSubscriptionQuery,
useCreateDomainPermissionSubscriptionMutation,
useGetDomainPermissionSubscriptionsPreviewQuery,
useUpdateDomainPermissionSubscriptionMutation,
useRemoveDomainPermissionSubscriptionMutation,
useTestDomainPermissionSubscriptionMutation,
};

View file

@ -169,6 +169,9 @@ export const gtsApi = createApi({
"HTTPHeaderBlocks",
"DefaultInteractionPolicies",
"InteractionRequest",
"DomainPermissionDraft",
"DomainPermissionExclude",
"DomainPermissionSubscription"
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View file

@ -26,6 +26,7 @@ import {
authorize as oauthAuthorize,
} from "../../../redux/oauth";
import { RootState } from '../../../redux/store';
import { Account } from '../../types/account';
export interface OauthTokenRequestBody {
client_id: string;
@ -58,7 +59,7 @@ const SETTINGS_URL = (getSettingsURL());
// https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#performing-multiple-requests-with-a-single-query
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
verifyCredentials: build.query<any, void>({
verifyCredentials: build.query<Account, void>({
providesTags: (_res, error) =>
error == undefined ? ["Auth"] : [],
async queryFn(_arg, api, _extraOpts, fetchWithBQ) {

View file

@ -53,8 +53,12 @@ export interface Account {
url: string,
avatar: string,
avatar_static: string,
avatar_description?: string,
avatar_media_id?: string,
header: string,
header_static: string,
header_description?: string,
header_media_id?: string,
followers_count: number,
following_count: number,
statuses_count: number,
@ -68,7 +72,7 @@ export interface Account {
}
export interface AccountSource {
fields: any[];
fields: any;
follow_requests_count: number;
language: string;
note: string;

View file

@ -19,11 +19,13 @@
import typia from "typia";
import { PermType } from "./perm";
import { Links } from "parse-link-header";
import { PermSubContentType } from "./permsubcontenttype";
export const validateDomainPerms = typia.createValidate<DomainPerm[]>();
/**
* A single domain permission entry (block or allow).
* A single domain permission entry (block, allow, draft, ignore).
*/
export interface DomainPerm {
id?: string;
@ -32,11 +34,14 @@ export interface DomainPerm {
private_comment?: string;
public_comment?: string;
created_at?: string;
created_by?: string;
subscription_id?: string;
// Internal processing keys; remove
// before serdes of domain perm.
// Keys that should be stripped before
// sending the domain permission (if imported).
permission_type?: PermType;
key?: string;
permType?: PermType;
suggest?: string;
valid?: boolean;
checked?: boolean;
@ -53,9 +58,9 @@ export interface MappedDomainPerms {
[key: string]: DomainPerm;
}
const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([
const domainPermStripOnImport: Set<keyof DomainPerm> = new Set([
"key",
"permType",
"permission_type",
"suggest",
"valid",
"checked",
@ -65,15 +70,14 @@ const domainPermInternalKeys: Set<keyof DomainPerm> = new Set([
]);
/**
* Returns true if provided DomainPerm Object key is
* "internal"; ie., it's just for our use, and it shouldn't
* be serialized to or deserialized from the GtS API.
* Returns true if provided DomainPerm Object key is one
* that should be stripped when importing a domain permission.
*
* @param key
* @returns
*/
export function isDomainPermInternalKey(key: keyof DomainPerm) {
return domainPermInternalKeys.has(key);
export function stripOnImport(key: keyof DomainPerm) {
return domainPermStripOnImport.has(key);
}
export interface ImportDomainPermsParams {
@ -94,3 +98,272 @@ export interface ExportDomainPermsParams {
action: "export" | "export-file";
exportType: "json" | "csv" | "plain";
}
/**
* Parameters for GET to /api/v1/admin/domain_permission_drafts.
*/
export interface DomainPermDraftSearchParams {
/**
* Show only drafts created by the given subscription ID.
*/
subscription_id?: string;
/**
* Return only drafts that target the given domain.
*/
domain?: string;
/**
* Filter on "block" or "allow" type drafts.
*/
permission_type?: PermType;
/**
* Return only items *OLDER* than the given max ID (for paging downwards).
* The item with the specified ID will not be included in the response.
*/
max_id?: string;
/**
* Return only items *NEWER* than the given since ID.
* The item with the specified ID will not be included in the response.
*/
since_id?: string;
/**
* Return only items immediately *NEWER* than the given min ID (for paging upwards).
* The item with the specified ID will not be included in the response.
*/
min_id?: string;
/**
* Number of items to return.
*/
limit?: number;
}
export interface DomainPermDraftSearchResp {
drafts: DomainPerm[];
links: Links | null;
}
export interface DomainPermDraftCreateParams {
/**
* Domain to create the permission draft for.
*/
domain: string;
/**
* Create a draft "allow" or a draft "block".
*/
permission_type: PermType;
/**
* Obfuscate the name of the domain when serving it publicly.
* Eg., `example.org` becomes something like `ex***e.org`.
*/
obfuscate?: boolean;
/**
* Public comment about this domain permission. This will be displayed
* alongside the domain permission if you choose to share permissions.
*/
public_comment?: string;
/**
* Private comment about this domain permission.
* Will only be shown to other admins, so this is a useful way of
* internally keeping track of why a certain domain ended up permissioned.
*/
private_comment?: string;
}
/**
* Parameters for GET to /api/v1/admin/domain_permission_excludes.
*/
export interface DomainPermExcludeSearchParams {
/**
* Return only excludes that target the given domain.
*/
domain?: string;
/**
* Return only items *OLDER* than the given max ID (for paging downwards).
* The item with the specified ID will not be included in the response.
*/
max_id?: string;
/**
* Return only items *NEWER* than the given since ID.
* The item with the specified ID will not be included in the response.
*/
since_id?: string;
/**
* Return only items immediately *NEWER* than the given min ID (for paging upwards).
* The item with the specified ID will not be included in the response.
*/
min_id?: string;
/**
* Number of items to return.
*/
limit?: number;
}
export interface DomainPermExcludeSearchResp {
excludes: DomainPerm[];
links: Links | null;
}
export interface DomainPermExcludeCreateParams {
/**
* Domain to create the permission exclude for.
*/
domain: string;
/**
* Private comment about this domain permission.
* Will only be shown to other admins, so this is a useful way of
* internally keeping track of why a certain domain ended up permissioned.
*/
private_comment?: string;
}
/**
* API model of one domain permission susbcription.
*/
export interface DomainPermSub {
/**
* The ID of the domain permission subscription.
*/
id: string;
/**
* The priority of the domain permission subscription.
*/
priority: number;
/**
* Time at which the subscription was created (ISO 8601 Datetime).
*/
created_at: string;
/**
* Title of this subscription, as set by admin who created or updated it.
*/
title: string;
/**
* The type of domain permission subscription (allow, block).
*/
permission_type: PermType;
/**
* If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect.
* If false, domain permissions from this subscription will come into force immediately.
*/
as_draft: boolean;
/**
* If true, this domain permission subscription will "adopt" domain permissions
* which already exist on the instance, and which meet the following conditions:
* 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present
* in the subscribed list. Such orphaned domain permissions will be given this
* subscription's subscription ID value and be managed by this subscription.
*/
adopt_orphans: boolean;
/**
* ID of the account that created this subscription.
*/
created_by: string;
/**
* URI to call in order to fetch the permissions list.
*/
uri: string;
/**
* MIME content type to use when parsing the permissions list.
*/
content_type: PermSubContentType;
/**
* (Optional) username to set for basic auth when doing a fetch of URI.
*/
fetch_username?: string;
/**
* (Optional) password to set for basic auth when doing a fetch of URI.
*/
fetch_password?: string;
/**
* Time at which the most recent fetch was attempted (ISO 8601 Datetime).
*/
fetched_at?: string;
/**
* Time of the most recent successful fetch (ISO 8601 Datetime).
*/
successfully_fetched_at?: string;
/**
* If most recent fetch attempt failed, this field will contain an error message related to the fetch attempt.
*/
error?: string;
/**
* Count of domain permission entries discovered at URI on last (successful) fetch.
*/
count: number;
}
/**
* Parameters for GET to /api/v1/admin/domain_permission_subscriptions.
*/
export interface DomainPermSubSearchParams {
/**
* Return only block or allow subscriptions.
*/
permission_type?: PermType;
/**
* Return only items *OLDER* than the given max ID (for paging downwards).
* The item with the specified ID will not be included in the response.
*/
max_id?: string;
/**
* Return only items *NEWER* than the given since ID.
* The item with the specified ID will not be included in the response.
*/
since_id?: string;
/**
* Return only items immediately *NEWER* than the given min ID (for paging upwards).
* The item with the specified ID will not be included in the response.
*/
min_id?: string;
/**
* Number of items to return.
*/
limit?: number;
}
export interface DomainPermSubCreateUpdateParams {
/**
* The priority of the domain permission subscription.
*/
priority?: number;
/**
* Title of this subscription, as set by admin who created or updated it.
*/
title?: string;
/**
* URI to call in order to fetch the permissions list.
*/
uri: string;
/**
* MIME content type to use when parsing the permissions list.
*/
content_type: PermSubContentType;
/**
* If true, domain permissions arising from this subscription will be created as drafts that must be approved by a moderator to take effect.
* If false, domain permissions from this subscription will come into force immediately.
*/
as_draft?: boolean;
/**
* If true, this domain permission subscription will "adopt" domain permissions
* which already exist on the instance, and which meet the following conditions:
* 1) they have no subscription ID (ie., they're "orphaned") and 2) they are present
* in the subscribed list. Such orphaned domain permissions will be given this
* subscription's subscription ID value and be managed by this subscription.
*/
adopt_orphans?: boolean;
/**
* (Optional) username to set for basic auth when doing a fetch of URI.
*/
fetch_username?: string;
/**
* (Optional) password to set for basic auth when doing a fetch of URI.
*/
fetch_password?: string;
/**
* The type of domain permission subscription to create or update (allow, block).
*/
permission_type: PermType;
}
export interface DomainPermSubSearchResp {
subs: DomainPermSub[];
links: Links | null;
}

View file

@ -25,6 +25,7 @@ export interface InstanceV1 {
description_text?: string;
short_description: string;
short_description_text?: string;
custom_css: string;
email: string;
version: string;
debug?: boolean;

View file

@ -0,0 +1,20 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type PermSubContentType = "text/plain" | "text/csv" | "application/json";

View file

@ -0,0 +1,67 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import isValidDomain from "is-valid-domain";
/**
* Validate the "domain" field of a form.
* @param domain
* @returns
*/
export function formDomainValidator(domain: string): string {
if (domain.length === 0) {
return "";
}
if (domain[domain.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(domain, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
export function urlValidator(urlStr: string): string {
if (urlStr.length === 0) {
return "";
}
let url: URL;
try {
url = new URL(urlStr);
} catch (e) {
return e.message;
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return `invalid protocol, must be http or https`;
}
return formDomainValidator(url.host);
}

View file

@ -41,3 +41,16 @@ export function UseOurInstanceAccount(account: AdminAccount): boolean {
return !account.domain && account.username == ourDomain;
}
/**
* Uppercase first letter of given string.
*/
export function useCapitalize(i?: string): string {
return useMemo(() => {
if (i === undefined) {
return "";
}
return i.charAt(0).toUpperCase() + i.slice(1);
}, [i]);
}

View file

@ -194,7 +194,8 @@ nav.menu-tree {
}
}
li.nested { /* any deeper nesting, just has indent */
/* Deeper nesting. */
li.nested {
a.title {
padding-left: 1rem;
font-weight: normal;
@ -210,11 +211,35 @@ nav.menu-tree {
background: $settings-nav-bg-hover;
}
}
&.active > a.title {
color: $fg-accent;
font-weight: bold;
}
&.active {
a.title {
color: $fg-accent;
font-weight: bold;
&.category {
& > a.title {
&::after {
content: "▶";
left: 0.8rem;
bottom: 0.1rem;
position: relative;
}
}
&.active {
& > a.title {
&::after {
content: "▼";
bottom: 0;
}
border-bottom: 0.15rem dotted $gray1;
}
}
li.nested > a.title {
padding-left: 2rem;
}
}
}
@ -441,6 +466,11 @@ section.with-sidebar > form {
.profile {
max-width: 42rem;
}
.file-input-with-image-description {
max-width: 100%;
width: 100%;
}
.overview {
display: flex;
@ -1329,6 +1359,115 @@ button.tab-button {
}
}
.domain-permission-drafts-view,
.domain-permission-excludes-view,
.domain-permission-subscriptions-view,
.domain-permission-subscriptions-preview {
.domain-permission-draft,
.domain-permission-exclude,
.domain-permission-subscription {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 0.5rem;
&.block {
border-left: 0.3rem solid $error3;
}
&.allow {
border-left: 0.3rem solid $green1;
}
&:hover {
border-color: $fg-accent;
}
.info-list {
border: none;
.info-list-entry {
background: none;
padding: 0;
}
}
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
> .mutation-button
> button {
font-size: 1rem;
line-height: 1rem;
}
}
}
}
.domain-permission-draft-details,
.domain-permission-exclude-details,
.domain-permission-subscription-details {
.info-list {
margin-top: 1rem;
}
}
.domain-permission-drafts-view,
.domain-permission-draft-details,
.domain-permission-subscriptions-view,
.domain-permission-subscription-details,
.domain-permission-subscriptions-preview {
dd.permission-type {
display: flex;
gap: 0.35rem;
align-items: center;
}
}
.domain-permission-subscription-details {
> .list > .entries > .perm-preview {
gap: 0.5rem;
}
> .perm-issue > b > code {
background: $info-bg;
padding: 0;
}
}
.domain-permission-subscription-title {
font-size: 1.2rem;
font-weight: bold;
}
.domain-permission-subscription-create,
.domain-permission-subscription-update {
gap: 1rem;
.password-show-hide {
display: flex;
gap: 0.5rem;
.form-field.text {
flex: 1;
}
.password-show-hide-toggle {
font-size: 1rem;
line-height: 1.4rem;
align-self: flex-end;
}
}
}
.domain-permission-subscription-remove,
.domain-permission-subscription-test {
gap: 1rem;
}
.instance-rules {
list-style-position: inside;
margin: 0;

View file

@ -22,32 +22,11 @@ import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useTextInput } from "../../../../lib/form";
import { useInstanceKeysExpireMutation } from "../../../../lib/query/admin/actions";
import isValidDomain from "is-valid-domain";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
export default function ExpireRemote({}) {
const domainField = useTextInput("domain", {
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
});
const [expire, expireResult] = useInstanceKeysExpireMutation();

View file

@ -119,7 +119,7 @@ export default function NewEmojiForm() {
label="Shortcode, must be unique among the instance's local emoji"
autoCapitalize="none"
spellCheck="false"
{...{pattern: "^\\w{2,30}$"}}
{...{pattern: "^\\w{1,30}$"}}
/>
<CategorySelect

View file

@ -22,7 +22,7 @@ import { useMemo } from "react";
import { useTextInput } from "../../../../lib/form";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
const shortcodeRegex = /^\w{2,30}$/;
const shortcodeRegex = /^\w{1,30}$/;
export default function useShortcode() {
const { data: emoji = [] } = useListEmojiQuery({
@ -42,8 +42,8 @@ export default function useShortcode() {
return "Shortcode already in use";
}
if (code.length < 2 || code.length > 30) {
return "Shortcode must be between 2 and 30 characters";
if (code.length < 1 || code.length > 30) {
return "Shortcode must be between 1 and 30 characters";
}
if (!shortcodeRegex.test(code)) {

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useEffect, useMemo } from "react";
import React, { useMemo } from "react";
import { useLocation, useParams } from "wouter";
import { PermType } from "../../../lib/types/perm";
import { useDeleteHeaderAllowMutation, useDeleteHeaderBlockMutation, useGetHeaderAllowQuery, useGetHeaderBlockQuery } from "../../../lib/query/admin/http-header-permissions";
@ -26,8 +26,7 @@ import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { SerializedError } from "@reduxjs/toolkit";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
import { useLazyGetAccountQuery } from "../../../lib/query/admin";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { useBaseUrl } from "../../../lib/navigation/util";
import BackButton from "../../../components/back-button";
import MutationButton from "../../../components/form/mutation-button";
@ -92,58 +91,19 @@ interface PermDeetsProps {
function PermDeets({
permType,
data: perm,
isLoading: isLoadingPerm,
isFetching: isFetchingPerm,
isError: isErrorPerm,
error: errorPerm,
isLoading,
isFetching,
isError,
error,
}: PermDeetsProps) {
const [ location ] = useLocation();
const baseUrl = useBaseUrl();
// Once we've loaded the perm, trigger
// getting the account that created it.
const [ getAccount, getAccountRes ] = useLazyGetAccountQuery();
useEffect(() => {
if (!perm) {
return;
}
getAccount(perm.created_by, true);
}, [getAccount, perm]);
// Load the createdByAccount if possible,
// returning a username lozenge with
// a link to the account.
const createdByAccount = useMemo(() => {
const {
data: account,
isLoading: isLoadingAccount,
isFetching: isFetchingAccount,
isError: isErrorAccount,
} = getAccountRes;
// Wait for query to finish, returning
// loading spinner in the meantime.
if (isLoadingAccount || isFetchingAccount || !perm) {
return <Loading />;
} else if (isErrorAccount || account === undefined) {
// Fall back to account ID.
return perm?.created_by;
}
return (
<Username
account={account}
linkTo={`~/settings/moderation/accounts/${account.id}`}
backLocation={`~${baseUrl}${location}`}
/>
);
}, [getAccountRes, perm, baseUrl, location]);
// Now wait til the perm itself is loaded.
if (isLoadingPerm || isFetchingPerm) {
// Wait til the perm itself is loaded.
if (isLoading || isFetching) {
return <Loading />;
} else if (isErrorPerm) {
return <Error error={errorPerm} />;
} else if (isError) {
return <Error error={error} />;
} else if (perm === undefined) {
throw "perm undefined";
}
@ -172,7 +132,13 @@ function PermDeets({
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>{createdByAccount}</dd>
<dd>
<UsernameLozenge
account={perm.created_by}
linkTo={`~/settings/moderation/accounts/${perm.created_by}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Header Name</dt>

View file

@ -27,6 +27,7 @@ import { PermType } from "../../../lib/types/perm";
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { SerializedError } from "@reduxjs/toolkit";
import HeaderPermCreateForm from "./create";
import { useCapitalize } from "../../../lib/util";
export default function HeaderPermsOverview() {
const [ location, setLocation ] = useLocation();
@ -41,9 +42,7 @@ export default function HeaderPermsOverview() {
}, [params]);
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
// Fetch desired perms, skipping
// the ones we don't want.

View file

@ -46,7 +46,7 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
const shortDescLimit = 500;
const descLimit = 5000;
const termsLimit = 5000;
const form = {
title: useTextInput("title", {
source: instance,
@ -66,6 +66,10 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}),
customCSS: useTextInput("custom_css", {
source: instance,
valueSelector: (s: InstanceV1) => s.custom_css
}),
terms: useTextInput("terms", {
source: instance,
// Select "raw" text version of parsed field for editing.
@ -191,7 +195,16 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
type="email"
/>
<TextArea
field={form.customCSS}
label={"Custom CSS"}
className="monospace"
rows={8}
autoCapitalize="none"
spellCheck="false"
/>
<MutationButton label="Save" result={result} disabled={false} />
</form>
);
}
}

View file

@ -21,7 +21,7 @@ import React, { ReactNode } from "react";
import { useSearchAccountsQuery } from "../../../../lib/query/admin";
import { PageableList } from "../../../../components/pageable-list";
import { useLocation } from "wouter";
import Username from "../../../../components/username";
import UsernameLozenge from "../../../../components/username-lozenge";
import { AdminAccount } from "../../../../lib/types/account";
export default function AccountsPending() {
@ -32,7 +32,7 @@ export default function AccountsPending() {
function itemToEntry(account: AdminAccount): ReactNode {
const acc = account.account;
return (
<Username
<UsernameLozenge
key={acc.acct}
account={account}
linkTo={`/${account.id}`}

View file

@ -26,8 +26,8 @@ import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { AdminAccount } from "../../../../lib/types/account";
import Username from "../../../../components/username";
import isValidDomain from "is-valid-domain";
import UsernameLozenge from "../../../../components/username-lozenge";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
export function AccountSearchForm() {
const [ location, setLocation ] = useLocation();
@ -45,28 +45,7 @@ export function AccountSearchForm() {
display_name: useTextInput("display_name", { defaultValue: urlQueryParams.get("display_name") ?? ""}),
by_domain: useTextInput("by_domain", {
defaultValue: urlQueryParams.get("by_domain") ?? "",
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
}),
email: useTextInput("email", { defaultValue: urlQueryParams.get("email") ?? ""}),
ip: useTextInput("ip", { defaultValue: urlQueryParams.get("ip") ?? ""}),
@ -114,7 +93,7 @@ export function AccountSearchForm() {
function itemToEntry(account: AdminAccount): ReactNode {
const acc = account.account;
return (
<Username
<UsernameLozenge
key={acc.acct}
account={account}
linkTo={`/${account.id}`}

View file

@ -39,37 +39,47 @@ import { NoArg } from "../../../lib/types/query";
import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util";
import { PermType } from "../../../lib/types/perm";
import isValidDomain from "is-valid-domain";
import { useCapitalize } from "../../../lib/util";
import { formDomainValidator } from "../../../lib/util/formvalidators";
export default function DomainPermDetail() {
const baseUrl = useBaseUrl();
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
const search = useSearch();
// Parse perm type from routing params, converting
// "blocks" => "block" and "allows" => "allow".
const params = useParams();
const permTypeRaw = params.permType;
if (permTypeRaw !== "blocks" && permTypeRaw !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
const permType = useMemo(() => {
return permTypeRaw.slice(0, -1) as PermType;
}, [permTypeRaw]);
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
// Conditionally fetch either domain blocks or domain
// allows depending on which perm type we're looking at.
const {
data: blocks = {},
isLoading: loadingBlocks,
isFetching: fetchingBlocks,
} = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const {
data: allows = {},
isLoading: loadingAllows,
isFetching: fetchingAllows,
} = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
let isLoading;
switch (permType) {
case "block":
isLoading = isLoadingDomainBlocks;
break;
case "allow":
isLoading = isLoadingDomainAllows;
break;
default:
throw "perm type unknown";
// Wait until we're done loading.
const loading = permType === "block"
? loadingBlocks || fetchingBlocks
: loadingAllows || fetchingAllows;
if (loading) {
return <Loading />;
}
// Parse domain from routing params.
let domain = params.domain ?? "unknown";
const search = useSearch();
if (domain === "view") {
// Retrieve domain from form field submission.
const searchParams = new URLSearchParams(search);
@ -81,36 +91,41 @@ export default function DomainPermDetail() {
domain = searchDomain;
}
// Normalize / decode domain (it may be URL-encoded).
// Normalize / decode domain
// (it may be URL-encoded).
domain = decodeURIComponent(domain);
// Check if we already have a perm of the desired type for this domain.
const existingPerm: DomainPerm | undefined = useMemo(() => {
if (permType == "block") {
return domainBlocks[domain];
} else {
return domainAllows[domain];
}
}, [domainBlocks, domainAllows, domain, permType]);
// Check if we already have a perm
// of the desired type for this domain.
const existingPerm = permType === "block"
? blocks[domain]
: allows[domain];
// Render different into content depending on
// if we have a perm already for this domain.
let infoContent: React.JSX.Element;
if (isLoading) {
infoContent = <Loading />;
} else if (existingPerm == undefined) {
infoContent = <span>No stored {permType} yet, you can add one below:</span>;
if (existingPerm === undefined) {
infoContent = (
<span>
No stored {permType} yet, you can add one below:
</span>
);
} else {
infoContent = (
<div className="info">
<i className="fa fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
<b>Editing domain permissions isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
<b>Editing existing domain {permTypeRaw} isn't implemented yet, <a href="https://github.com/superseriousbusiness/gotosocial/issues/1198" target="_blank" rel="noopener noreferrer">check here for progress</a></b>
</div>
);
}
return (
<div>
<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
<h1 className="text-cutoff">
<BackButton to={`~${baseUrl}/${permTypeRaw}`} />
{" "}
Domain {permType} for {domain}
</h1>
{infoContent}
<DomainPermForm
defaultDomain={domain}
@ -143,28 +158,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)
domain: useTextInput("domain", {
source: perm,
defaultValue: defaultDomain,
validator: (v: string) => {
if (v.length === 0) {
return "";
}
if (v[v.length-1] === ".") {
return "invalid domain";
}
const valid = isValidDomain(v, {
subdomain: true,
wildcard: false,
allowUnicode: true,
topLevel: false,
});
if (valid) {
return "";
}
return "invalid domain";
}
validator: formDomainValidator,
}),
obfuscate: useBoolInput("obfuscate", { source: perm }),
commentPrivate: useTextInput("private_comment", { source: perm }),
@ -209,9 +203,7 @@ function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps)
const [submitForm, submitFormResult] = useFormSubmit(form, [addTrigger, addResult], { changedOnly: false });
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
const [location, setLocation] = useLocation();

View file

@ -0,0 +1,43 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
export function DomainPermissionDraftHelpText() {
return (
<>
Domain permission drafts are domain block or domain allow entries that are not yet in force.
<br/>
You can choose to accept or remove a draft.
</>
);
}
export function DomainPermissionDraftDocsLink() {
return (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-drafts"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission drafts (opens in a new tab)
</a>
);
}

View file

@ -0,0 +1,210 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useLocation, useParams } from "wouter";
import Loading from "../../../../components/loading";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import {
useAcceptDomainPermissionDraftMutation,
useGetDomainPermissionDraftQuery,
useRemoveDomainPermissionDraftMutation
} from "../../../../lib/query/admin/domain-permissions/drafts";
import { Error as ErrorC } from "../../../../components/error";
import UsernameLozenge from "../../../../components/username-lozenge";
import MutationButton from "../../../../components/form/mutation-button";
import { useBoolInput, useTextInput } from "../../../../lib/form";
import { Checkbox, Select } from "../../../../components/form/inputs";
import { PermType } from "../../../../lib/types/perm";
export default function DomainPermissionDraftDetail() {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`;
const params = useParams();
let id = params.permDraftId as string | undefined;
if (!id) {
throw "no perm ID";
}
const {
data: permDraft,
isLoading,
isFetching,
isError,
error,
} = useGetDomainPermissionDraftQuery(id);
if (isLoading || isFetching) {
return <Loading />;
} else if (isError) {
return <ErrorC error={error} />;
} else if (permDraft === undefined) {
return <ErrorC error={new Error("permission draft was undefined")} />;
}
const created = permDraft.created_at ? new Date(permDraft.created_at).toDateString(): "unknown";
const domain = permDraft.domain;
const permType = permDraft.permission_type;
if (!permType) {
return <ErrorC error={new Error("permission_type was undefined")} />;
}
const publicComment = permDraft.public_comment ?? "[none]";
const privateComment = permDraft.private_comment ?? "[none]";
const subscriptionID = permDraft.subscription_id ?? "[none]";
return (
<div className="domain-permission-draft-details">
<h1><BackButton to={backLocation} /> Domain Permission Draft Detail</h1>
<dl className="info-list">
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permDraft.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permDraft.created_by}
linkTo={`~/settings/moderation/accounts/${permDraft.created_by}`}
backLocation={`~${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Domain</dt>
<dd>{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Permission type</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>Private comment</dt>
<dd>{privateComment}</dd>
</div>
<div className="info-list-entry">
<dt>Public comment</dt>
<dd>{publicComment}</dd>
</div>
<div className="info-list-entry">
<dt>Subscription ID</dt>
<dd>{subscriptionID}</dd>
</div>
</dl>
<HandleDraft
id={id}
permType={permType}
backLocation={backLocation}
/>
</div>
);
}
function HandleDraft({ id, permType, backLocation }: { id: string, permType: PermType, backLocation: string }) {
const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation();
const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation();
const [_location, setLocation] = useLocation();
const form = {
acceptOrRemove: useTextInput("accept_or_remove", { defaultValue: "accept" }),
overwrite: useBoolInput("overwrite"),
exclude_target: useBoolInput("exclude_target"),
};
const onClick = (e) => {
e.preventDefault();
if (form.acceptOrRemove.value === "accept") {
const overwrite = form.overwrite.value;
accept({id, overwrite, permType}).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
} else {
const exclude_target = form.exclude_target.value;
remove({id, exclude_target}).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}
};
return (
<form>
<Select
field={form.acceptOrRemove}
label="Accept or remove draft"
options={
<>
<option value="accept">Accept</option>
<option value="remove">Remove</option>
</>
}
></Select>
{ form.acceptOrRemove.value === "accept" &&
<>
<Checkbox
field={form.overwrite}
label={`Overwrite any existing ${permType} for this domain`}
/>
</>
}
{ form.acceptOrRemove.value === "remove" &&
<>
<Checkbox
field={form.exclude_target}
label={`Add a domain permission exclude for this domain`}
/>
</>
}
<MutationButton
label={
form.acceptOrRemove.value === "accept"
? `Accept ${permType}`
: "Remove draft"
}
type="button"
className={
form.acceptOrRemove.value === "accept"
? "button"
: "button danger"
}
onClick={onClick}
disabled={false}
showError={true}
result={
form.acceptOrRemove.value === "accept"
? acceptResult
: removeResult
}
/>
</form>
);
}

View file

@ -0,0 +1,293 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { useAcceptDomainPermissionDraftMutation, useLazySearchDomainPermissionDraftsQuery, useRemoveDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts";
import { DomainPerm } from "../../../../lib/types/domain-permission";
import { Error as ErrorC } from "../../../../components/error";
import { Select, TextInput } from "../../../../components/form/inputs";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import { useCapitalize } from "../../../../lib/util";
import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common";
export default function DomainPermissionDraftsSearch() {
return (
<div className="domain-permission-drafts-view">
<div className="form-section-docs">
<h1>Domain Permission Drafts</h1>
<p>
You can use the form below to search through domain permission drafts.
<br/>
<DomainPermissionDraftHelpText />
</p>
<DomainPermissionDraftDocsLink />
</div>
<DomainPermissionDraftsSearchForm />
</div>
);
}
function DomainPermissionDraftsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchDrafts, searchRes ] = useLazySearchDomainPermissionDraftsQuery();
const form = {
subscription_id: useTextInput("subscription_id", { defaultValue: urlQueryParams.get("subscription_id") ?? "" }),
domain: useTextInput("domain", {
defaultValue: urlQueryParams.get("domain") ?? "",
validator: formDomainValidator,
}),
permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, trigger default
// search (first page, no filtering).
useEffect(() => {
if (hasParams) {
searchDrafts(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchDrafts,
location,
setLocation,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(draft: DomainPerm): ReactNode {
return (
<DraftListEntry
key={draft.id}
permDraft={draft}
linkTo={`/drafts/${draft.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.permission_type}
label="Permission type"
options={
<>
<option value="">Any</option>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
></Select>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.drafts}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No drafts found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface DraftEntryProps {
permDraft: DomainPerm;
linkTo: string;
backLocation: string;
}
function DraftListEntry({ permDraft, linkTo, backLocation }: DraftEntryProps) {
const [ _location, setLocation ] = useLocation();
const [ accept, acceptResult ] = useAcceptDomainPermissionDraftMutation();
const [ remove, removeResult ] = useRemoveDomainPermissionDraftMutation();
const domain = permDraft.domain;
const permType = permDraft.permission_type;
const permTypeUpper = useCapitalize(permType);
if (!permType) {
return <ErrorC error={new Error("permission_type was undefined")} />;
}
const publicComment = permDraft.public_comment ?? "[none]";
const privateComment = permDraft.private_comment ?? "[none]";
const subscriptionID = permDraft.subscription_id ?? "[none]";
const id = permDraft.id;
if (!id) {
return <ErrorC error={new Error("id was undefined")} />;
}
const title = `${permTypeUpper} ${domain}`;
return (
<span
className={`pseudolink domain-permission-draft entry ${permType}`}
aria-label={title}
title={title}
onClick={() => {
// When clicking on a draft, direct
// to the detail view for that draft.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<h3>{title}</h3>
<dl className="info-list">
<div className="info-list-entry">
<dt>Domain:</dt>
<dd className="text-cutoff">{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Permission type:</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>Private comment:</dt>
<dd className="text-cutoff">{privateComment}</dd>
</div>
<div className="info-list-entry">
<dt>Public comment:</dt>
<dd>{publicComment}</dd>
</div>
<div className="info-list-entry">
<dt>Subscription:</dt>
<dd className="text-cutoff">{subscriptionID}</dd>
</div>
</dl>
<div className="action-buttons">
<MutationButton
label={`Accept ${permType}`}
title={`Accept ${permType}`}
type="button"
className="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
accept({ id, permType });
}}
disabled={false}
showError={true}
result={acceptResult}
/>
<MutationButton
label={`Remove draft`}
title={`Remove draft`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
remove({ id });
}}
disabled={false}
showError={true}
result={removeResult}
/>
</div>
</span>
);
}

View file

@ -0,0 +1,119 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import useFormSubmit from "../../../../lib/form/submit";
import { useCreateDomainPermissionDraftMutation } from "../../../../lib/query/admin/domain-permissions/drafts";
import { useBoolInput, useRadioInput, useTextInput } from "../../../../lib/form";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import MutationButton from "../../../../components/form/mutation-button";
import { Checkbox, RadioGroup, TextArea, TextInput } from "../../../../components/form/inputs";
import { useLocation } from "wouter";
import { DomainPermissionDraftDocsLink, DomainPermissionDraftHelpText } from "./common";
export default function DomainPermissionDraftNew() {
const [ _location, setLocation ] = useLocation();
const form = {
domain: useTextInput("domain", {
validator: formDomainValidator,
}),
permission_type: useRadioInput("permission_type", {
options: {
block: "Block domain",
allow: "Allow domain",
}
}),
obfuscate: useBoolInput("obfuscate"),
public_comment: useTextInput("public_comment"),
private_comment: useTextInput("private_comment"),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateDomainPermissionDraftMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to drafts overview.
setLocation(`/drafts/search`);
}
},
});
return (
<form
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Draft</h2>
<p><DomainPermissionDraftHelpText /></p>
<DomainPermissionDraftDocsLink />
</div>
<RadioGroup
field={form.permission_type}
/>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<TextArea
field={form.private_comment}
label={"Private comment"}
placeholder="This domain is like unto a clown car full of clowns, I suggest we block it forthwith."
autoCapitalize="sentences"
rows={3}
/>
<TextArea
field={form.public_comment}
label={"Public comment"}
placeholder="Bad posters"
autoCapitalize="sentences"
rows={3}
/>
<Checkbox
field={form.obfuscate}
label="Obfuscate domain in public lists"
/>
<MutationButton
label="Save"
result={result}
disabled={
!form.domain.value ||
!form.domain.valid ||
!form.permission_type.value
}
/>
</form>
);
}

View file

@ -0,0 +1,54 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
export function DomainPermissionExcludeHelpText() {
return (
<>
Domain permission excludes prevent permissions for a domain (and all
subdomains) from being auomatically managed by domain permission subscriptions.
<br/>
For example, if you create an exclude entry for <code>example.org</code>, then
a blocklist or allowlist subscription will <em>exclude</em> entries for <code>example.org</code>
and any of its subdomains (<code>sub.example.org</code>, <code>another.sub.example.org</code> etc.)
when creating domain permission drafts and domain blocks/allows.
<br/>
This functionality allows you to manually manage permissions for excluded domains,
in cases where you know you definitely do or don't want to federate with a given domain,
no matter what entries are contained in a domain permission subscription.
<br/>
Note that by itself, creation of an exclude entry for a given domain does not affect
federation with that domain at all, it is only useful in combination with permission subscriptions.
</>
);
}
export function DomainPermissionExcludeDocsLink() {
return (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-excludes"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission excludes (opens in a new tab)
</a>
);
}

View file

@ -0,0 +1,119 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useLocation, useParams } from "wouter";
import Loading from "../../../../components/loading";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import { Error as ErrorC } from "../../../../components/error";
import UsernameLozenge from "../../../../components/username-lozenge";
import { useDeleteDomainPermissionExcludeMutation, useGetDomainPermissionExcludeQuery } from "../../../../lib/query/admin/domain-permissions/excludes";
import MutationButton from "../../../../components/form/mutation-button";
export default function DomainPermissionExcludeDetail() {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}`;
const params = useParams();
let id = params.excludeId as string | undefined;
if (!id) {
throw "no perm ID";
}
const {
data: permExclude,
isLoading,
isFetching,
isError,
error,
} = useGetDomainPermissionExcludeQuery(id);
if (isLoading || isFetching) {
return <Loading />;
} else if (isError) {
return <ErrorC error={error} />;
} else if (permExclude === undefined) {
return <ErrorC error={new Error("permission exclude was undefined")} />;
}
const created = permExclude.created_at ? new Date(permExclude.created_at).toDateString(): "unknown";
const domain = permExclude.domain;
const privateComment = permExclude.private_comment ?? "[none]";
return (
<div className="domain-permission-exclude-details">
<h1><BackButton to={backLocation} /> Domain Permission Exclude Detail</h1>
<dl className="info-list">
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permExclude.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permExclude.created_by}
linkTo={`~/settings/moderation/accounts/${permExclude.created_by}`}
backLocation={`~${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Domain</dt>
<dd>{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Private comment</dt>
<dd>{privateComment}</dd>
</div>
</dl>
<HandleExclude
id={id}
backLocation={backLocation}
/>
</div>
);
}
function HandleExclude({ id, backLocation}: {id: string, backLocation: string}) {
const [_location, setLocation] = useLocation();
const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation();
return (
<MutationButton
label={`Delete exclude`}
title={`Delete exclude`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteExclude(id).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}}
disabled={false}
showError={true}
result={deleteResult}
/>
);
}

View file

@ -0,0 +1,235 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { useDeleteDomainPermissionExcludeMutation, useLazySearchDomainPermissionExcludesQuery } from "../../../../lib/query/admin/domain-permissions/excludes";
import { DomainPerm } from "../../../../lib/types/domain-permission";
import { Error as ErrorC } from "../../../../components/error";
import { Select, TextInput } from "../../../../components/form/inputs";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common";
export default function DomainPermissionExcludesSearch() {
return (
<div className="domain-permission-excludes-view">
<div className="form-section-docs">
<h1>Domain Permission Excludes</h1>
<p>
You can use the form below to search through domain permission excludes.
<br/>
<DomainPermissionExcludeHelpText />
</p>
<DomainPermissionExcludeDocsLink />
</div>
<DomainPermissionExcludesSearchForm />
</div>
);
}
function DomainPermissionExcludesSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchExcludes, searchRes ] = useLazySearchDomainPermissionExcludesQuery();
const form = {
domain: useTextInput("domain", {
defaultValue: urlQueryParams.get("domain") ?? "",
validator: formDomainValidator,
}),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, trigger default
// search (first page, no filtering).
useEffect(() => {
if (hasParams) {
searchExcludes(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchExcludes,
location,
setLocation,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(exclude: DomainPerm): ReactNode {
return (
<ExcludeListEntry
key={exclude.id}
permExclude={exclude}
linkTo={`/excludes/${exclude.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.excludes}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No excludes found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}
interface ExcludeEntryProps {
permExclude: DomainPerm;
linkTo: string;
backLocation: string;
}
function ExcludeListEntry({ permExclude, linkTo, backLocation }: ExcludeEntryProps) {
const [ _location, setLocation ] = useLocation();
const [ deleteExclude, deleteResult ] = useDeleteDomainPermissionExcludeMutation();
const domain = permExclude.domain;
const privateComment = permExclude.private_comment ?? "[none]";
const id = permExclude.id;
if (!id) {
return <ErrorC error={new Error("id was undefined")} />;
}
return (
<span
className={`pseudolink domain-permission-exclude entry`}
aria-label={`Exclude ${domain}`}
title={`Exclude ${domain}`}
onClick={() => {
// When clicking on a exclude, direct
// to the detail view for that exclude.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<dl className="info-list">
<div className="info-list-entry">
<dt>Domain:</dt>
<dd className="text-cutoff">{domain}</dd>
</div>
<div className="info-list-entry">
<dt>Private comment:</dt>
<dd className="text-cutoff">{privateComment}</dd>
</div>
</dl>
<div className="action-buttons">
<MutationButton
label={`Delete exclude`}
title={`Delete exclude`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteExclude(id);
}}
disabled={false}
showError={true}
result={deleteResult}
/>
</div>
</span>
);
}

View file

@ -0,0 +1,90 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import useFormSubmit from "../../../../lib/form/submit";
import { useCreateDomainPermissionExcludeMutation } from "../../../../lib/query/admin/domain-permissions/excludes";
import { useTextInput } from "../../../../lib/form";
import { formDomainValidator } from "../../../../lib/util/formvalidators";
import MutationButton from "../../../../components/form/mutation-button";
import { TextArea, TextInput } from "../../../../components/form/inputs";
import { useLocation } from "wouter";
import { DomainPermissionExcludeDocsLink, DomainPermissionExcludeHelpText } from "./common";
export default function DomainPermissionExcludeNew() {
const [ _location, setLocation ] = useLocation();
const form = {
domain: useTextInput("domain", {
validator: formDomainValidator,
}),
private_comment: useTextInput("private_comment"),
};
const [formSubmit, result] = useFormSubmit(
form,
useCreateDomainPermissionExcludeMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to excludes overview.
setLocation(`/excludes/search`);
}
},
});
return (
<form
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Exclude</h2>
<p><DomainPermissionExcludeHelpText /></p>
<DomainPermissionExcludeDocsLink />
</div>
<TextInput
field={form.domain}
label={`Domain (without "https://" prefix)`}
placeholder="example.org"
autoCapitalize="none"
spellCheck="false"
/>
<TextArea
field={form.private_comment}
label={"Private comment"}
placeholder="Created an exclude for this domain because we should manage it manually."
autoCapitalize="sentences"
rows={3}
/>
<MutationButton
label="Save"
result={result}
disabled={!form.domain.value || !form.domain.valid}
/>
</form>
);
}

View file

@ -30,6 +30,7 @@ import type { MappedDomainPerms } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
import { PermType } from "../../../lib/types/perm";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useCapitalize } from "../../../lib/util";
export default function DomainPermissionsOverview() {
const baseUrl = useBaseUrl();
@ -42,9 +43,7 @@ export default function DomainPermissionsOverview() {
const permType = params.permType.slice(0, -1) as PermType;
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
return permType.charAt(0).toUpperCase() + permType.slice(1);
}, [permType]);
const permTypeUpper = useCapitalize(permType);
// Fetch / wait for desired perms to load.
const { data: blocks, isLoading: isLoadingBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });

View file

@ -0,0 +1,181 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo } from "react";
import { useLocation } from "wouter";
import { DomainPermSub } from "../../../../lib/types/domain-permission";
import { yesOrNo } from "../../../../lib/util";
export function DomainPermissionSubscriptionHelpText() {
return (
<>
Domain permission subscriptions allow your instance to "subscribe" to a list of block or allows at a given url.
<br/>
Every 24 hours, each subscribed list is fetched by your instance, and any discovered
permissions in each list are loaded into your instance as blocks/allows/drafts.
</>
);
}
export function DomainPermissionSubscriptionDocsLink() {
return (
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#domain-permission-subscriptions"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about domain permission subscriptions (opens in a new tab)
</a>
);
}
export interface SubscriptionEntryProps {
permSub: DomainPermSub;
linkTo: string;
backLocation: string;
}
export function SubscriptionListEntry({ permSub, linkTo, backLocation }: SubscriptionEntryProps) {
const [ _location, setLocation ] = useLocation();
const permType = permSub.permission_type;
if (!permType) {
throw "permission_type was undefined";
}
const {
priority,
title,
uri,
as_draft: asDraft,
adopt_orphans: adoptOrphans,
content_type: contentType,
fetched_at: fetchedAt,
successfully_fetched_at: successfullyFetchedAt,
count,
} = permSub;
const ariaLabel = useMemo(() => {
let ariaLabel = "";
// Prepend title.
if (title.length !== 0) {
ariaLabel += `${title}, create `;
} else {
ariaLabel += "Create ";
}
// Add perm type.
ariaLabel += permType;
// Alter wording
// if using drafts.
if (asDraft) {
ariaLabel += " drafts from ";
} else {
ariaLabel += "s from ";
}
// Add url.
ariaLabel += uri;
return ariaLabel;
}, [title, permType, asDraft, uri]);
let fetchedAtStr = "never";
if (fetchedAt) {
fetchedAtStr = new Date(fetchedAt).toDateString();
}
let successfullyFetchedAtStr = "never";
if (successfullyFetchedAt) {
successfullyFetchedAtStr = new Date(successfullyFetchedAt).toDateString();
}
return (
<span
className={`pseudolink domain-permission-subscription entry`}
aria-label={ariaLabel}
title={ariaLabel}
onClick={() => {
// When clicking on a subscription, direct
// to the detail view for that subscription.
setLocation(linkTo, {
// Store the back location in history so
// the detail view can use it to return to
// this page (including query parameters).
state: { backLocation: backLocation }
});
}}
role="link"
tabIndex={0}
>
<dl className="info-list">
{ permSub.title !== "" &&
<span className="domain-permission-subscription-title">
{title}
</span>
}
<div className="info-list-entry">
<dt>Priority:</dt>
<dd>{priority}</dd>
</div>
<div className="info-list-entry">
<dt>Permission type:</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>URL:</dt>
<dd className="text-cutoff">{uri}</dd>
</div>
<div className="info-list-entry">
<dt>Content type:</dt>
<dd>{contentType}</dd>
</div>
<div className="info-list-entry">
<dt>Create as draft:</dt>
<dd>{yesOrNo(asDraft)}</dd>
</div>
<div className="info-list-entry">
<dt>Adopt orphans:</dt>
<dd>{yesOrNo(adoptOrphans)}</dd>
</div>
<div className="info-list-entry">
<dt>Last fetch attempt:</dt>
<dd className="text-cutoff">{fetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Last successful fetch:</dt>
<dd className="text-cutoff">{successfullyFetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Discovered {permType}s:</dt>
<dd>{count}</dd>
</div>
</dl>
</span>
);
}

View file

@ -0,0 +1,456 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useState } from "react";
import { useLocation, useParams } from "wouter";
import { useBaseUrl } from "../../../../lib/navigation/util";
import BackButton from "../../../../components/back-button";
import { useGetDomainPermissionSubscriptionQuery, useRemoveDomainPermissionSubscriptionMutation, useTestDomainPermissionSubscriptionMutation, useUpdateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions";
import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form";
import FormWithData from "../../../../lib/form/form-with-data";
import { DomainPerm, DomainPermSub } from "../../../../lib/types/domain-permission";
import MutationButton from "../../../../components/form/mutation-button";
import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs";
import useFormSubmit from "../../../../lib/form/submit";
import UsernameLozenge from "../../../../components/username-lozenge";
import { urlValidator } from "../../../../lib/util/formvalidators";
import { PageableList } from "../../../../components/pageable-list";
export default function DomainPermissionSubscriptionDetail() {
const params = useParams();
let id = params.permSubId as string | undefined;
if (!id) {
throw "no permSub ID";
}
return (
<FormWithData
dataQuery={useGetDomainPermissionSubscriptionQuery}
queryArg={id}
DataForm={DomainPermSubForm}
/>
);
}
function DomainPermSubForm({ data: permSub }: { data: DomainPermSub }) {
const baseUrl = useBaseUrl();
const backLocation: string = history.state?.backLocation ?? `~${baseUrl}/subscriptions/search`;
return (
<div className="domain-permission-subscription-details">
<h1><BackButton to={backLocation} /> Domain Permission Subscription Detail</h1>
<DomainPermSubDetails permSub={permSub} />
<UpdateDomainPermSub permSub={permSub} />
<TestDomainPermSub permSub={permSub} />
<DeleteDomainPermSub permSub={permSub} backLocation={backLocation} />
</div>
);
}
function DomainPermSubDetails({ permSub }: { permSub: DomainPermSub }) {
const [ location ] = useLocation();
const baseUrl = useBaseUrl();
const permType = permSub.permission_type;
if (!permType) {
throw "permission_type was undefined";
}
const created = new Date(permSub.created_at).toDateString();
let fetchedAtStr = "never";
if (permSub.fetched_at) {
fetchedAtStr = new Date(permSub.fetched_at).toDateString();
}
let successfullyFetchedAtStr = "never";
if (permSub.successfully_fetched_at) {
successfullyFetchedAtStr = new Date(permSub.successfully_fetched_at).toDateString();
}
return (
<dl className="info-list">
<div className="info-list-entry">
<dt>Permission type:</dt>
<dd className={`permission-type ${permType}`}>
<i
aria-hidden={true}
className={`fa fa-${permType === "allow" ? "check" : "close"}`}
></i>
{permType}
</dd>
</div>
<div className="info-list-entry">
<dt>ID</dt>
<dd className="monospace">{permSub.id}</dd>
</div>
<div className="info-list-entry">
<dt>Created</dt>
<dd><time dateTime={permSub.created_at}>{created}</time></dd>
</div>
<div className="info-list-entry">
<dt>Created By</dt>
<dd>
<UsernameLozenge
account={permSub.created_by}
linkTo={`~/settings/moderation/accounts/${permSub.created_by}`}
backLocation={`~${baseUrl}${location}`}
/>
</dd>
</div>
<div className="info-list-entry">
<dt>Last fetch attempt:</dt>
<dd>{fetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Last successful fetch:</dt>
<dd>{successfullyFetchedAtStr}</dd>
</div>
<div className="info-list-entry">
<dt>Discovered {permSub.permission_type}s:</dt>
<dd>{permSub.count}</dd>
</div>
</dl>
);
}
function UpdateDomainPermSub({ permSub }: { permSub: DomainPermSub }) {
const [ showPassword, setShowPassword ] = useState(false);
const form = {
priority: useNumberInput("priority", { source: permSub }),
uri: useTextInput("uri", {
source: permSub,
validator: urlValidator,
}),
content_type: useTextInput("content_type", { source: permSub }),
title: useTextInput("title", { source: permSub }),
as_draft: useBoolInput("as_draft", { source: permSub }),
adopt_orphans: useBoolInput("adopt_orphans", { source: permSub }),
useBasicAuth: useBoolInput("useBasicAuth", {
defaultValue:
(permSub.fetch_password !== undefined && permSub.fetch_password !== "") ||
(permSub.fetch_username !== undefined && permSub.fetch_username !== ""),
nosubmit: true
}),
fetch_username: useTextInput("fetch_username", {
source: permSub
}),
fetch_password: useTextInput("fetch_password", {
source: permSub
}),
};
const [submitUpdate, updateResult] = useFormSubmit(
form,
useUpdateDomainPermissionSubscriptionMutation(),
{
changedOnly: true,
customizeMutationArgs: (mutationData) => {
// Clear username + password if they were set,
// but user has selected to not use basic auth.
if (!form.useBasicAuth.value) {
if (permSub.fetch_username !== undefined && permSub.fetch_username !== "") {
mutationData["fetch_username"] = "";
}
if (permSub.fetch_password !== undefined && permSub.fetch_password !== "") {
mutationData["fetch_password"] = "";
}
}
// Remove useBasicAuth if included.
delete mutationData["useBasicAuth"];
// Modify mutation argument to
// include ID and permission type.
return {
id: permSub.id,
permType: permSub.permission_type,
formData: mutationData,
};
},
onFinish: res => {
// On a successful response that returns data,
// clear the fetch_username and fetch_password
// fields if they weren't set on the returned sub.
if (res.data) {
if (res.data.fetch_username === undefined || res.data.fetch_username === "") {
form.fetch_username.setter("");
}
if (res.data.fetch_password === undefined || res.data.fetch_password === "") {
form.fetch_password.setter("");
}
}
}
}
);
const submitDisabled = () => {
// If no basic auth, we don't care what
// fetch_password and fetch_username are.
if (!form.useBasicAuth.value) {
return false;
}
// Either of fetch_password or fetch_username must be set.
return !(form.fetch_password.value || form.fetch_username.value);
};
return (
<form
className="domain-permission-subscription-update"
onSubmit={submitUpdate}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<h2>Edit Subscription</h2>
<TextInput
field={form.title}
label={`Subscription title`}
placeholder={`Some List of ${permSub.permission_type === "block" ? "Baddies" : "Goodies"}`}
autoCapitalize="words"
spellCheck="false"
/>
<NumberInput
field={form.priority}
label={`Subscription priority (0-255)`}
type="number"
min="0"
max="255"
/>
<TextInput
field={form.uri}
label={`Permission list URL (http or https)`}
placeholder="https://example.org/files/some_list_somewhere"
autoCapitalize="none"
spellCheck="false"
type="url"
/>
<Select
field={form.content_type}
label="Content type"
options={
<>
<option value="text/csv">CSV</option>
<option value="application/json">JSON</option>
<option value="text/plain">Plain</option>
</>
}
/>
<Checkbox
label={
<>
<>Use </>
<a
href="https://en.wikipedia.org/wiki/Basic_access_authentication"
target="_blank"
rel="noreferrer"
>basic auth</a>
<> when fetching</>
</>
}
field={form.useBasicAuth}
/>
{ form.useBasicAuth.value &&
<>
<TextInput
field={form.fetch_username}
label={`Basic auth username`}
autoCapitalize="none"
spellCheck="false"
autoComplete="off"
required={form.useBasicAuth.value && !form.fetch_password.value}
/>
<div className="password-show-hide">
<TextInput
field={form.fetch_password}
label={`Basic auth password`}
autoCapitalize="none"
spellCheck="false"
type={showPassword ? "" : "password"}
autoComplete="off"
required={form.useBasicAuth.value && !form.fetch_username.value}
/>
<button
className="password-show-hide-toggle"
type="button"
title={!showPassword ? "Show password" : "Hide password"}
onClick={e => {
e.preventDefault();
setShowPassword(!showPassword);
}}
>
{ !showPassword ? "Show" : "Hide" }
</button>
</div>
</>
}
<Checkbox
label="Adopt orphan permissions"
field={form.adopt_orphans}
/>
<Checkbox
label="Create permissions as drafts"
field={form.as_draft}
/>
{ !form.as_draft.value &&
<div className="info">
<i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i>
<b>
Unchecking "create permissions as drafts" means that permissions found on the
subscribed list will be enforced immediately the next time the list is fetched.
<br/>
If you're subscribing to a block list, this means that blocks will be created
automatically from the given list, potentially severing any existing follow
relationships with accounts on the blocked domain.
<br/>
Before saving, make sure this is what you really want to do, and consider
creating domain excludes for domains that you want to manage manually.
</b>
</div>
}
<MutationButton
label="Save"
result={updateResult}
disabled={submitDisabled()}
/>
</form>
);
}
function DeleteDomainPermSub({ permSub, backLocation }: { permSub: DomainPermSub, backLocation: string }) {
const permType = permSub.permission_type;
if (!permType) {
throw "permission_type was undefined";
}
const [_location, setLocation] = useLocation();
const [ removeSub, result ] = useRemoveDomainPermissionSubscriptionMutation();
const removeChildren = useBoolInput("remove_children", { defaultValue: false });
return (
<form className="domain-permission-subscription-remove">
<h2>Remove Subscription</h2>
<Checkbox
label={`Also remove any ${permType}s created by this subscription`}
field={removeChildren}
/>
<MutationButton
label={`Remove`}
title={`Remove`}
type="button"
className="button danger"
onClick={(e) => {
e.preventDefault();
const id = permSub.id;
const remove_children = removeChildren.value as boolean;
removeSub({ id, remove_children }).then(res => {
if ("data" in res) {
setLocation(backLocation);
}
});
}}
disabled={false}
showError={true}
result={result}
/>
</form>
);
}
function TestDomainPermSub({ permSub }: { permSub: DomainPermSub }) {
const permType = permSub.permission_type;
if (!permType) {
throw "permission_type was undefined";
}
const [ testSub, testRes ] = useTestDomainPermissionSubscriptionMutation();
const onSubmit = (e) => {
e.preventDefault();
testSub(permSub.id);
};
// Function to map an item to a list entry.
function itemToEntry(perm: DomainPerm): ReactNode {
return (
<span className="text-cutoff entry perm-preview">
<strong>{ perm.domain }</strong>
{ perm.public_comment && <>({ perm.public_comment })</> }
</span>
);
}
return (
<>
<form
className="domain-permission-subscription-test"
onSubmit={onSubmit}
>
<h2>Test Subscription</h2>
Click the "test" button to instruct your instance to do a test
fetch and parse of the {permType} list at the subscription URI.
<br/>
If the fetch is successful, you will see a list of {permType}s
(or {permType} drafts) that *would* be created by this subscription,
along with the public comment for each {permType} (if applicable).
<br/>
The test does not actually create those {permType}s in your database.
<MutationButton
disabled={false}
label={"Test"}
result={testRes}
/>
</form>
{ testRes.data && "error" in testRes.data
? <div className="info perm-issue">
<i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i>
<b>
The following issue was encountered when doing a fetch + parse:
<br/><code>{ testRes.data.error }</code>
<br/>This may be due to a temporary outage at the remote URL,
or you may wish to check your subscription settings and test again.
</b>
</div>
: <>
{ testRes.data && `${testRes.data?.length} ${permType}s would be created by this subscription:`}
<PageableList
isLoading={testRes.isLoading}
isSuccess={testRes.isSuccess}
items={testRes.data}
itemToEntry={itemToEntry}
isError={testRes.isError}
error={testRes.error}
emptyMessage={<b>No entries!</b>}
/>
</>
}
</>
);
}

View file

@ -0,0 +1,170 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode, useEffect, useMemo } from "react";
import { useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import MutationButton from "../../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import { useLazySearchDomainPermissionSubscriptionsQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions";
import { DomainPermSub } from "../../../../lib/types/domain-permission";
import { Select } from "../../../../components/form/inputs";
import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText, SubscriptionListEntry } from "./common";
export default function DomainPermissionSubscriptionsSearch() {
return (
<div className="domain-permission-subscriptions-view">
<div className="form-section-docs">
<h1>Domain Permission Subscriptions</h1>
<p>
You can use the form below to search through domain permission
subscriptions, sorted by creation time (newer to older).
<br/>
<DomainPermissionSubscriptionHelpText />
</p>
<DomainPermissionSubscriptionDocsLink />
</div>
<DomainPermissionSubscriptionsSearchForm />
</div>
);
}
function DomainPermissionSubscriptionsSearchForm() {
const [ location, setLocation ] = useLocation();
const search = useSearch();
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
const hasParams = urlQueryParams.size != 0;
const [ searchSubscriptions, searchRes ] = useLazySearchDomainPermissionSubscriptionsQuery();
const form = {
permission_type: useTextInput("permission_type", { defaultValue: urlQueryParams.get("permission_type") ?? "" }),
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
};
// On mount, if urlQueryParams were provided,
// trigger the search. For example, if page
// was accessed at /search?origin=local&limit=20,
// then run a search with origin=local and
// limit=20 and immediately render the results.
//
// If no urlQueryParams set, trigger default
// search (first page, no filtering).
useEffect(() => {
if (hasParams) {
searchSubscriptions(Object.fromEntries(urlQueryParams));
} else {
setLocation(location + "?limit=20");
}
}, [
urlQueryParams,
hasParams,
searchSubscriptions,
location,
setLocation,
]);
// Rather than triggering the search directly,
// the "submit" button changes the location
// based on form field params, and lets the
// useEffect hook above actually do the search.
function submitQuery(e) {
e.preventDefault();
// Parse query parameters.
const entries = Object.entries(form).map(([k, v]) => {
// Take only defined form fields.
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
return null;
}
return [[k, v.value]];
}).flatMap(kv => {
// Remove any nulls.
return kv || [];
});
const searchParams = new URLSearchParams(entries);
setLocation(location + "?" + searchParams.toString());
}
// Location to return to when user clicks "back" on the detail view.
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
// Function to map an item to a list entry.
function itemToEntry(permSub: DomainPermSub): ReactNode {
return (
<SubscriptionListEntry
key={permSub.id}
permSub={permSub}
linkTo={`/subscriptions/${permSub.id}`}
backLocation={backLocation}
/>
);
}
return (
<>
<form
onSubmit={submitQuery}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<Select
field={form.permission_type}
label="Permission type"
options={
<>
<option value="">Any</option>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
></Select>
<Select
field={form.limit}
label="Items per page"
options={
<>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</>
}
></Select>
<MutationButton
disabled={false}
label={"Search"}
result={searchRes}
/>
</form>
<PageableList
isLoading={searchRes.isLoading}
isFetching={searchRes.isFetching}
isSuccess={searchRes.isSuccess}
items={searchRes.data?.subs}
itemToEntry={itemToEntry}
isError={searchRes.isError}
error={searchRes.error}
emptyMessage={<b>No subscriptions found that match your query.</b>}
prevNextLinks={searchRes.data?.links}
/>
</>
);
}

View file

@ -0,0 +1,230 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useState } from "react";
import useFormSubmit from "../../../../lib/form/submit";
import { useCreateDomainPermissionSubscriptionMutation } from "../../../../lib/query/admin/domain-permissions/subscriptions";
import { useBoolInput, useNumberInput, useTextInput } from "../../../../lib/form";
import { urlValidator } from "../../../../lib/util/formvalidators";
import MutationButton from "../../../../components/form/mutation-button";
import { Checkbox, NumberInput, Select, TextInput } from "../../../../components/form/inputs";
import { useLocation } from "wouter";
import { DomainPermissionSubscriptionDocsLink, DomainPermissionSubscriptionHelpText } from "./common";
export default function DomainPermissionSubscriptionNew() {
const [ _location, setLocation ] = useLocation();
const useBasicAuth = useBoolInput("useBasicAuth", { defaultValue: false });
const form = {
priority: useNumberInput("priority", { defaultValue: 0 }),
uri: useTextInput("uri", {
validator: urlValidator,
}),
content_type: useTextInput("content_type", { defaultValue: "text/csv" }),
permission_type: useTextInput("permission_type", { defaultValue: "block" }),
title: useTextInput("title"),
as_draft: useBoolInput("as_draft", { defaultValue: true }),
adopt_orphans: useBoolInput("adopt_orphans", { defaultValue: false }),
fetch_username: useTextInput("fetch_username", {
nosubmit: !useBasicAuth.value
}),
fetch_password: useTextInput("fetch_password", {
nosubmit: !useBasicAuth.value
}),
};
const [ showPassword, setShowPassword ] = useState(false);
const [formSubmit, result] = useFormSubmit(
form,
useCreateDomainPermissionSubscriptionMutation(),
{
changedOnly: false,
onFinish: (res) => {
if (res.data) {
// Creation successful,
// redirect to subscription detail.
setLocation(`/subscriptions/${res.data.id}`);
}
},
});
const submitDisabled = () => {
// URI required.
if (!form.uri.value || !form.uri.valid) {
return true;
}
// If no basic auth, we don't care what
// fetch_password and fetch_username are.
if (!useBasicAuth.value) {
return false;
}
// Either of fetch_password or fetch_username must be set.
return !(form.fetch_password.value || form.fetch_username.value);
};
return (
<form
className="domain-permission-subscription-create"
onSubmit={formSubmit}
// Prevent password managers
// trying to fill in fields.
autoComplete="off"
>
<div className="form-section-docs">
<h2>New Domain Permission Subscription</h2>
<p><DomainPermissionSubscriptionHelpText /></p>
<DomainPermissionSubscriptionDocsLink />
</div>
<TextInput
field={form.title}
label={`Subscription title`}
placeholder={`Some List of ${form.permission_type.value === "block" ? "Baddies" : "Goodies"}`}
autoCapitalize="words"
spellCheck="false"
/>
<NumberInput
field={form.priority}
label={`Subscription priority (0-255)`}
type="number"
min="0"
max="255"
/>
<Select
field={form.permission_type}
label="Permission type"
options={
<>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
/>
<TextInput
field={form.uri}
label={`Permission list URL (http or https)`}
placeholder="https://example.org/files/some_list_somewhere"
autoCapitalize="none"
spellCheck="false"
type="url"
/>
<Select
field={form.content_type}
label="Content type"
options={
<>
<option value="text/csv">CSV</option>
<option value="application/json">JSON</option>
<option value="text/plain">Plain</option>
</>
}
/>
<Checkbox
label={
<>
<>Use </>
<a
href="https://en.wikipedia.org/wiki/Basic_access_authentication"
target="_blank"
rel="noreferrer"
>basic auth</a>
<> when fetching</>
</>
}
field={useBasicAuth}
/>
{ useBasicAuth.value &&
<>
<TextInput
field={form.fetch_username}
label={`Basic auth username`}
autoCapitalize="none"
spellCheck="false"
autoComplete="off"
required={useBasicAuth.value && !form.fetch_password.value}
/>
<div className="password-show-hide">
<TextInput
field={form.fetch_password}
label={`Basic auth password`}
autoCapitalize="none"
spellCheck="false"
type={showPassword ? "" : "password"}
autoComplete="off"
required={useBasicAuth.value && !form.fetch_username.value}
/>
<button
className="password-show-hide-toggle"
type="button"
title={!showPassword ? "Show password" : "Hide password"}
onClick={e => {
e.preventDefault();
setShowPassword(!showPassword);
}}
>
{ !showPassword ? "Show" : "Hide" }
</button>
</div>
</>
}
<Checkbox
label="Adopt orphan permissions"
field={form.adopt_orphans}
/>
<Checkbox
label="Create permissions as drafts"
field={form.as_draft}
/>
{ !form.as_draft.value &&
<div className="info">
<i className="fa fa-fw fa-exclamation-circle" aria-hidden="true"></i>
<b>
Unchecking "create permissions as drafts" means that permissions found on the
subscribed list will be enforced immediately the next time the list is fetched.
<br/>
If you're subscribing to a block list, this means that blocks will be created
automatically from the given list, potentially severing any existing follow
relationships with accounts on the blocked domain.
<br/>
Before saving, make sure this is what you really want to do, and consider
creating domain excludes for domains that you want to manage manually.
</b>
</div>
}
<MutationButton
label="Save"
result={result}
disabled={submitDisabled()}
/>
</form>
);
}

View file

@ -0,0 +1,100 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { ReactNode } from "react";
import { useTextInput } from "../../../../lib/form";
import { PageableList } from "../../../../components/pageable-list";
import { useLocation } from "wouter";
import { useGetDomainPermissionSubscriptionsPreviewQuery } from "../../../../lib/query/admin/domain-permissions/subscriptions";
import { DomainPermSub } from "../../../../lib/types/domain-permission";
import { Select } from "../../../../components/form/inputs";
import { DomainPermissionSubscriptionDocsLink, SubscriptionListEntry } from "./common";
import { PermType } from "../../../../lib/types/perm";
export default function DomainPermissionSubscriptionsPreview() {
return (
<div className="domain-permission-subscriptions-preview">
<div className="form-section-docs">
<h1>Domain Permission Subscriptions Preview</h1>
<p>
You can use the form below to view through domain permission subscriptions sorted by priority (high to low).
<br/>
This reflects the order in which they will actually be fetched by your instance, with higher-priority subscriptions
creating permissions first, followed by lower-priority subscriptions.
</p>
<DomainPermissionSubscriptionDocsLink />
</div>
<DomainPermissionSubscriptionsPreviewForm />
</div>
);
}
function DomainPermissionSubscriptionsPreviewForm() {
const [ location, _setLocation ] = useLocation();
const permType = useTextInput("permission_type", { defaultValue: "block" });
const {
data: permSubs,
isLoading,
isFetching,
isSuccess,
isError,
error,
} = useGetDomainPermissionSubscriptionsPreviewQuery(permType.value as PermType);
// Function to map an item to a list entry.
function itemToEntry(permSub: DomainPermSub): ReactNode {
return (
<SubscriptionListEntry
key={permSub.id}
permSub={permSub}
linkTo={`/subscriptions/${permSub.id}`}
backLocation={location}
/>
);
}
return (
<>
<form>
<Select
field={permType}
label="Permission type"
options={
<>
<option value="block">Block</option>
<option value="allow">Allow</option>
</>
}
></Select>
</form>
<PageableList
isLoading={isLoading}
isFetching={isFetching}
isSuccess={isSuccess}
items={permSubs}
itemToEntry={itemToEntry}
isError={isError}
error={error}
emptyMessage={<b>No {permType.value}list subscriptions found.</b>}
/>
</>
);
}

View file

@ -116,6 +116,62 @@ function ModerationDomainPermsMenu() {
itemUrl="import-export"
icon="fa-floppy-o"
/>
<MenuItem
name="Drafts"
itemUrl="drafts"
defaultChild="search"
icon="fa-pencil"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New draft"
itemUrl="new"
icon="fa-plus"
/>
</MenuItem>
<MenuItem
name="Excludes"
itemUrl="excludes"
defaultChild="search"
icon="fa-minus-square"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New exclude"
itemUrl="new"
icon="fa-plus"
/>
</MenuItem>
<MenuItem
name="Subscriptions"
itemUrl="subscriptions"
defaultChild="search"
icon="fa-cloud-download"
>
<MenuItem
name="Search"
itemUrl="search"
icon="fa-list"
/>
<MenuItem
name="New subscription"
itemUrl="new"
icon="fa-plus"
/>
<MenuItem
name="Preview"
itemUrl="preview"
icon="fa-eye"
/>
</MenuItem>
</MenuItem>
);
}

View file

@ -25,7 +25,7 @@ import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util";
import { AdminReport } from "../../../lib/types/report";
@ -99,7 +99,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Reported account</dt>
<dd>
<Username
<UsernameLozenge
account={target}
linkTo={`~/settings/moderation/accounts/${target.id}`}
backLocation={`~${baseUrl}${location}`}
@ -110,7 +110,7 @@ function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Reported by</dt>
<dd>
<Username
<UsernameLozenge
account={from}
linkTo={`~/settings/moderation/accounts/${from.id}`}
backLocation={`~${baseUrl}${location}`}
@ -173,7 +173,7 @@ function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
<div className="info-list-entry">
<dt>Handled by</dt>
<dd>
<Username
<UsernameLozenge
account={handled_by}
linkTo={`~/settings/moderation/accounts/${handled_by.id}`}
backLocation={`~${baseUrl}${location}`}

View file

@ -25,7 +25,7 @@ import { PageableList } from "../../../components/pageable-list";
import { Select } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useLocation, useSearch } from "wouter";
import Username from "../../../components/username";
import UsernameLozenge from "../../../components/username-lozenge";
import { AdminReport } from "../../../lib/types/report";
export default function ReportsSearch() {
@ -206,7 +206,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
<div className="info-list-entry">
<dt>Reported account:</dt>
<dd className="text-cutoff">
<Username
<UsernameLozenge
account={target}
classNames={["text-cutoff report-byline"]}
/>
@ -216,7 +216,7 @@ function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
<div className="info-list-entry">
<dt>Reported by:</dt>
<dd className="text-cutoff reported-by">
<Username account={from} />
<UsernameLozenge account={from} />
</dd>
</div>

View file

@ -29,6 +29,16 @@ import DomainPermDetail from "./domain-permissions/detail";
import AccountsSearch from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
import DomainPermissionDraftsSearch from "./domain-permissions/drafts";
import DomainPermissionDraftNew from "./domain-permissions/drafts/new";
import DomainPermissionDraftDetail from "./domain-permissions/drafts/detail";
import DomainPermissionExcludeDetail from "./domain-permissions/excludes/detail";
import DomainPermissionExcludesSearch from "./domain-permissions/excludes";
import DomainPermissionExcludeNew from "./domain-permissions/excludes/new";
import DomainPermissionSubscriptionsSearch from "./domain-permissions/subscriptions";
import DomainPermissionSubscriptionNew from "./domain-permissions/subscriptions/new";
import DomainPermissionSubscriptionDetail from "./domain-permissions/subscriptions/detail";
import DomainPermissionSubscriptionsPreview from "./domain-permissions/subscriptions/preview";
/*
EXPORTED COMPONENTS
@ -139,6 +149,16 @@ function ModerationDomainPermsRouter() {
<Switch>
<Route path="/import-export" component={ImportExport} />
<Route path="/process" component={ImportExport} />
<Route path="/drafts/search" component={DomainPermissionDraftsSearch} />
<Route path="/drafts/new" component={DomainPermissionDraftNew} />
<Route path="/drafts/:permDraftId" component={DomainPermissionDraftDetail} />
<Route path="/excludes/search" component={DomainPermissionExcludesSearch} />
<Route path="/excludes/new" component={DomainPermissionExcludeNew} />
<Route path="/excludes/:excludeId" component={DomainPermissionExcludeDetail} />
<Route path="/subscriptions/search" component={DomainPermissionSubscriptionsSearch} />
<Route path="/subscriptions/new" component={DomainPermissionSubscriptionNew} />
<Route path="/subscriptions/preview" component={DomainPermissionSubscriptionsPreview} />
<Route path="/subscriptions/:permSubId" component={DomainPermissionSubscriptionDetail} />
<Route path="/:permType" component={DomainPermissionsOverview} />
<Route path="/:permType/:domain" component={DomainPermDetail} />
<Route><Redirect to="/blocks"/></Route>

View file

@ -20,7 +20,7 @@
import React from "react";
import { useVerifyCredentialsQuery } from "../../../lib/query/oauth";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
import { Error as ErrorC } from "../../../components/error";
import BasicSettings from "./basic-settings";
import InteractionPolicySettings from "./interaction-policy-settings";
@ -38,7 +38,11 @@ export default function PostSettings() {
}
if (isError) {
return <Error error={error} />;
return <ErrorC error={error} />;
}
if (!account) {
return <ErrorC error={new Error("account was undefined")} />;
}
return (

View file

@ -45,6 +45,7 @@ import { useAccountThemesQuery } from "../../lib/query/user";
import { useUpdateCredentialsMutation } from "../../lib/query/user";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useInstanceV1Query } from "../../lib/query/gts-api";
import { Account } from "../../lib/types/account";
export default function UserProfile() {
return (
@ -55,7 +56,11 @@ export default function UserProfile() {
);
}
function UserProfileForm({ data: profile }) {
interface UserProfileFormProps {
data: Account;
}
function UserProfileForm({ data: profile }: UserProfileFormProps) {
/*
User profile update form keys
- bool bot
@ -133,6 +138,9 @@ function UserProfileForm({ data: profile }) {
}
});
const noAvatarSet = !profile.avatar_media_id;
const noHeaderSet = !profile.header_media_id;
return (
<form className="user-profile" onSubmit={submitForm}>
<h1>Profile</h1>
@ -146,33 +154,37 @@ function UserProfileForm({ data: profile }) {
role={profile.role}
/>
<div className="file-input-with-image-description">
<fieldset className="file-input-with-image-description">
<legend>Header</legend>
<FileInput
label="Header"
label="Upload file"
field={form.header}
accept="image/png, image/jpeg, image/webp, image/gif"
/>
<TextInput
field={form.headerDescription}
label="Header image description"
label="Image description; only settable if not using default header"
placeholder="A green field with pink flowers."
autoCapitalize="sentences"
disabled={noHeaderSet && !form.header.value}
/>
</div>
</fieldset>
<div className="file-input-with-image-description">
<fieldset className="file-input-with-image-description">
<legend>Avatar</legend>
<FileInput
label="Avatar (1:1 images look best)"
label="Upload file (1:1 images look best)"
field={form.avatar}
accept="image/png, image/jpeg, image/webp, image/gif"
/>
<TextInput
field={form.avatarDescription}
label="Avatar image description"
label="Image description; only settable if not using default avatar"
placeholder="A cute drawing of a smiling sloth."
autoCapitalize="sentences"
disabled={noAvatarSet && !form.avatar.value}
/>
</div>
</fieldset>
<div className="theme">
<div>

View file

@ -2922,9 +2922,9 @@ create-require@^1.1.0:
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-spawn@^7.0.2:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
@ -3213,9 +3213,9 @@ electron-to-chromium@^1.4.668:
integrity sha512-jeWaIta2rIG2FzHaYIhSuVWqC6KJYo7oSBX4Jv7g+aVujKztfvdpf+n6MGwZdC5hQXbax4nntykLH2juIQrfPg==
elliptic@^6.5.3, elliptic@^6.5.4:
version "6.5.7"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.7.tgz#8ec4da2cb2939926a1b9a73619d768207e647c8b"
integrity sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==
version "6.6.0"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.0.tgz#5919ec723286c1edf28685aa89261d4761afa210"
integrity sha512-dpwoQcLc/2WLQvJvLRHKZ+f9FgOdjnq11rurqwekGQygGPsYSK29OMMD2WalatiqQ+XGFDglTNixpPfI+lpaAA==
dependencies:
bn.js "^4.11.9"
brorand "^1.1.0"
@ -4134,9 +4134,9 @@ http-parser-js@>=0.5.1:
integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==
http-proxy-middleware@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f"
integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==
version "2.0.7"
resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6"
integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==
dependencies:
"@types/http-proxy" "^1.17.8"
http-proxy "^1.18.1"
@ -5020,20 +5020,15 @@ mute-stream@0.0.8:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
nanoid@^3.3.6:
version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
nanoid@^3.3.6, nanoid@^3.3.7:
version "3.3.8"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
nanoid@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
nanoid@^5.0.9:
version "5.0.9"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.9.tgz#977dcbaac055430ce7b1e19cf0130cea91a20e50"
integrity sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==
natural-compare@^1.4.0:
version "1.4.0"

View file

@ -91,7 +91,7 @@ Polls can have up to
<li><a href="#contact">Contact</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#languages">Languages</a></li>
<li><a href="#signup">Register an Account on {{ .instance.Title -}}</li>
<li><a href="#signup">Register an Account on {{ .instance.Title -}}</a></li>
<li><a href="#rules">Rules</a></li>
<li><a href="#terms">Terms and Conditions</a></li>
<li><a href="#moderated-servers">Moderated Servers</a></li>
@ -172,7 +172,7 @@ Polls can have up to
<p>
ActivityPub instances federate with other instances by exchanging data with them over the network.
Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments.
This exchange of data can prevented for instances on specific domains via a domain block created
This exchange of data can be prevented for instances on specific domains via a domain block created
by an instance admin. When an instance is domain blocked by another instance:
</p>
<ul>
@ -190,4 +190,4 @@ Polls can have up to
</div>
</section>
</main>
{{- end }}
{{- end }}

View file

@ -29,27 +29,27 @@
<ul class="applist nodot" role="group">
<li class="applist-entry">
<div class="applist-text">
<p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p>
<p><strong>Pinafore</strong> is a web client designed for speed and simplicity.</p>
<a
href="https://semaphore.social/"
href="https://pinafore.social/"
rel="nofollow noreferrer noopener"
target="_blank"
>
Use Semaphore
Use Pinafore
</a>
</div>
<svg
role="img"
aria-labelledby="semaphore-title semaphore-desc"
aria-labelledby="pinafore-title pinafore-desc"
class="applist-logo redraw"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 146 120"
viewBox="0 0 10000 10000"
width="100"
height="100"
>
<title id="semaphore-title">The Semaphore logo</title>
<desc id="semaphore-desc">A waving flag</desc>
<path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path>
<title id="pinafore-title">The Pinafore logo</title>
<desc id="pinafore-desc">A sailboat</desc>
<path d="M9212 5993H5987V823c1053 667 2747 2177 3225 5170zM3100 2690A12240 12240 0 01939 6035h2161zm676 7210h2448a3067 3067 0 003067-3067H5052V627a527 527 0 00-1052 0v6206H709a3067 3067 0 003067 3067z"></path>
</svg>
</li>
<li class="applist-entry">
@ -115,4 +115,4 @@
</ul>
</div>
</section>
{{- end }}
{{- end }}

View file

@ -32,10 +32,16 @@
{{- range .stylesheets }}
<link rel="preload" href="{{- . -}}" as="style">
{{- end }}
{{- if .instance.CustomCSS }}
<link rel="preload" href="/custom.css" as="style">
{{- end }}
<link rel="stylesheet" href="/assets/dist/_colors.css">
<link rel="stylesheet" href="/assets/dist/base.css">
<link rel="stylesheet" href="/assets/dist/page.css">
{{- range .stylesheets }}
<link rel="stylesheet" href="{{- . -}}">
{{- end }}
{{- if .instance.CustomCSS }}
<link rel="stylesheet" href="/custom.css">
{{- end }}
{{- end }}