Merge branch 'ogp'
This commit is contained in:
commit
b16fbc9d78
|
@ -166,7 +166,6 @@ impl Preference for UserPreferencesV0 {
|
|||
} else {
|
||||
(i - last_default) as u8 - 2
|
||||
};
|
||||
eprintln!("{} {} {} {}", toggle_enabled, distance, i, last_default);
|
||||
commands.push(Command::Move {
|
||||
toggle_enabled,
|
||||
distance
|
||||
|
@ -194,7 +193,6 @@ impl Preference for UserPreferencesV0 {
|
|||
} else {
|
||||
(i - last_enabled) as u8 - 2
|
||||
};
|
||||
eprintln!("{} {} {} {}", toggle_enabled, distance, i, last_enabled);
|
||||
commands.push(Command::Move {
|
||||
toggle_enabled,
|
||||
distance
|
||||
|
|
42
src/util.rs
42
src/util.rs
|
@ -1,3 +1,21 @@
|
|||
use time::{Date, Month, OffsetDateTime};
|
||||
|
||||
/// The start of the COVID-19 lockdowns
|
||||
///
|
||||
/// This is used as an epoch in order to convert from a given date to an integer seed. This is
|
||||
/// specified as part of the algorithm for randomly selecting from a weighted list.
|
||||
pub const COVID_EPOCH: Date = match Date::from_calendar_date(2020, Month::January, 26) {
|
||||
Ok(d) => d,
|
||||
Err(_) => Date::MIN, // This never runs, but we can't unwrap, so this is what we're stuck with
|
||||
};
|
||||
|
||||
/// A state to use as an initial state before seeding
|
||||
///
|
||||
/// Think of this as a ::new() method for a pcg64 generator. Subsiquent calls should be
|
||||
/// made to the [`pcg64_seed()`] methods or the [`seed_with_date()`]/[`seed_with_today()`]
|
||||
/// methods
|
||||
pub const INITIAL_STATE: u128 = 1312_1312_1312;
|
||||
|
||||
pub fn pcg64(state: &mut u128) -> u64 {
|
||||
let mut x = *state;
|
||||
pcg64_iterstate(state);
|
||||
|
@ -18,3 +36,27 @@ pub fn pcg64_seed(state: &mut u128, bytes: &[u8]) {
|
|||
}
|
||||
pcg64_iterstate(state);
|
||||
}
|
||||
|
||||
/// Seed the generator with a given [`Date`] object
|
||||
pub fn seed_with_date(state: &mut u128, date: Date) {
|
||||
pcg64_seed(
|
||||
state,
|
||||
&(
|
||||
(date - COVID_EPOCH)
|
||||
.whole_days()
|
||||
as u32
|
||||
).to_le_bytes()
|
||||
)
|
||||
}
|
||||
|
||||
/// Seed the generator with the [`Date`] object representing today's date
|
||||
///
|
||||
/// Uses the system's local time, or falls back to UTC
|
||||
pub fn seed_with_today(state: &mut u128) {
|
||||
seed_with_date(
|
||||
state,
|
||||
OffsetDateTime::now_local()
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||||
.date()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,22 +1,10 @@
|
|||
use crate::{
|
||||
user_preferences::ParseError,
|
||||
util::{pcg64, pcg64_seed},
|
||||
};
|
||||
use crate::{user_preferences::ParseError, util::{self, pcg64, pcg64_seed, seed_with_date, seed_with_today}};
|
||||
|
||||
use std::{collections::BTreeMap, hash::Hash};
|
||||
use std::fmt::Debug;
|
||||
use std::cmp::Eq;
|
||||
|
||||
use time::{Date, Month, OffsetDateTime};
|
||||
|
||||
/// The start of the COVID-19 lockdowns
|
||||
///
|
||||
/// This is used as an epoch in order to convert from a given date to an integer seed. This is
|
||||
/// specified as part of the algorithm for randomly selecting from a weighted list.
|
||||
pub const COVID_EPOCH: Date = match Date::from_calendar_date(2020, Month::January, 26) {
|
||||
Ok(d) => d,
|
||||
Err(_) => Date::MIN, // This never runs, but we can't unwrap, so this is what we're stuck with
|
||||
};
|
||||
use time::Date;
|
||||
|
||||
/// A list of pronouns and their associated weights, used for random selection
|
||||
///
|
||||
|
@ -82,41 +70,37 @@ impl<Loot: Eq + Ord + Hash + Copy + Debug> WeightedTable<Loot> {
|
|||
///
|
||||
/// The date is generated for the system's time and timezone
|
||||
pub fn select_today(&self, seed: &[u8]) -> Loot {
|
||||
self.select_on_date(
|
||||
seed,
|
||||
OffsetDateTime::now_local()
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||||
.date()
|
||||
)
|
||||
let mut generator = util::INITIAL_STATE;
|
||||
|
||||
seed_with_today(&mut generator);
|
||||
pcg64_seed(&mut generator, seed);
|
||||
|
||||
self.select(&mut generator)
|
||||
}
|
||||
|
||||
/// Randomly select a pronoun set for a given date and name.
|
||||
///
|
||||
/// Is a wrapper for calling [`WeightedTable::select`] with the given date mixed into the seed.
|
||||
pub fn select_on_date(&self, seed: &[u8], date: Date) -> Loot {
|
||||
let mut new_seed: Vec<u8> = Vec::with_capacity(seed.len() + 4);
|
||||
new_seed.extend(
|
||||
(
|
||||
(date - COVID_EPOCH)
|
||||
.whole_days()
|
||||
as u32
|
||||
).to_le_bytes()
|
||||
);
|
||||
new_seed.extend(seed);
|
||||
self.select(&new_seed)
|
||||
let mut generator = util::INITIAL_STATE;
|
||||
|
||||
seed_with_date(&mut generator, date);
|
||||
pcg64_seed(&mut generator, seed);
|
||||
|
||||
self.select(&mut generator)
|
||||
}
|
||||
|
||||
/// Randomly select a pronoun set for a given seed
|
||||
/// Randomly select a pronoun set from a given pre-seeded genenator
|
||||
///
|
||||
/// You're probably looking for [`select_today()`] or [`select_on_date()`]
|
||||
///
|
||||
/// This function is *pure*, and any randomness is produced internally using PRNG seeded with
|
||||
/// the given date and seed. That is to say, for any given seed, this table must always
|
||||
/// produce the same pronoun set.
|
||||
pub fn select(&self, seed: &[u8]) -> Loot {
|
||||
pub fn select(&self, generator: &mut u128) -> Loot {
|
||||
let (rollable_table, sum_weights) = self.rollable_table();
|
||||
|
||||
let mut generator: u128 = 131213121312;
|
||||
pcg64_seed(&mut generator, seed);
|
||||
let random = pcg64(&mut generator) % sum_weights;
|
||||
let random = pcg64(generator) % sum_weights;
|
||||
|
||||
rollable_table.iter()
|
||||
.filter(|(weight, _)| random < *weight)
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
*.otf filter=lfs diff=lfs merge=lfs -text
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "pronouns-today-web"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
@ -11,3 +11,14 @@ actix-web = "3"
|
|||
log = "0.4"
|
||||
env_logger = "0.9"
|
||||
askama = "0.10"
|
||||
rusttype = { version = "0.9.2", optional = true }
|
||||
image = { version = "0.23.14", optional = true }
|
||||
lazy_static = { version = "1.4.0", optional = true }
|
||||
|
||||
[features]
|
||||
# Enables support for generating images for Open Graph Protocol cards. This means that
|
||||
# when someone shares a link to their pronoun page on Twitter, Discord, or another app
|
||||
# that supports OGP, the card will include a visual of their pronouns.
|
||||
ogp_images = ["rusttype", "image", "lazy_static"]
|
||||
|
||||
default = ["ogp_images"]
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,72 @@
|
|||
#![cfg(feature = "ogp_images")]
|
||||
|
||||
use image::{Pixel, Rgb, Rgba};
|
||||
use pronouns_today::util::pcg64;
|
||||
|
||||
pub fn generate_color_pair(state: &mut u128) -> ([u8; 3], [u8; 3]) {
|
||||
let mut background = random_color(state);
|
||||
let mut foreground;
|
||||
loop {
|
||||
foreground = random_color(state);
|
||||
if meets_criteria(&foreground, &background) {
|
||||
break;
|
||||
}
|
||||
background = random_color(state);
|
||||
if meets_criteria(&foreground, &background) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(foreground, background)
|
||||
}
|
||||
|
||||
pub fn generate_color_pair_rgba(state: &mut u128) -> (Rgba<u8>, Rgba<u8>) {
|
||||
let (color1, color2) = generate_color_pair(state);
|
||||
|
||||
let (color1, color2): (Rgb<u8>, Rgb<u8>) = (
|
||||
color1.into(),
|
||||
color2.into(),
|
||||
);
|
||||
|
||||
(color1.to_rgba(), color2.to_rgba())
|
||||
}
|
||||
|
||||
pub fn relative_lum(color: &[u8; 3]) -> f32 {
|
||||
let color_mapped: Vec<f32> = color.iter()
|
||||
.map(|val| {
|
||||
let val = *val as f32 / 255.0;
|
||||
if val < 0.03928 {
|
||||
val / 12.92
|
||||
} else {
|
||||
((val+0.055)/1.055).powf(2.4)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return 0.2126 * color_mapped[0] + 0.7152 * color_mapped[1] + 0.0722 * color_mapped[2]
|
||||
}
|
||||
|
||||
pub fn check_contrast(color1: &[u8; 3], color2: &[u8; 3]) -> f32 {
|
||||
let a = 0.05 + relative_lum(color1);
|
||||
let b = 0.05 + relative_lum(color2);
|
||||
(a / b).max(b / a)
|
||||
}
|
||||
|
||||
pub fn get_saturation(color: &[u8; 3]) -> f32 {
|
||||
let chroma_max = *(color.iter().max().unwrap());
|
||||
let chroma_min = *(color.iter().min().unwrap());
|
||||
return ((chroma_max - chroma_min) as f32) / chroma_max as f32;
|
||||
}
|
||||
|
||||
pub fn random_color(state: &mut u128) -> [u8; 3] {
|
||||
let random = pcg64(state).to_le_bytes();
|
||||
return [
|
||||
random[0],
|
||||
random[1],
|
||||
random[2],
|
||||
]
|
||||
}
|
||||
|
||||
pub fn meets_criteria(foreground: &[u8; 3], background: &[u8; 3]) -> bool {
|
||||
return check_contrast(foreground, background) >= 5.0 &&
|
||||
get_saturation(background) < 0.65 &&
|
||||
get_saturation(foreground) < 0.65
|
||||
}
|
113
web/src/main.rs
113
web/src/main.rs
|
@ -1,3 +1,6 @@
|
|||
pub mod ogp_images;
|
||||
pub mod contrast;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
|
@ -5,24 +8,35 @@ use actix_web::dev::HttpResponseBuilder;
|
|||
use actix_web::http::{header, StatusCode};
|
||||
use actix_web::middleware::normalize::TrailingSlash;
|
||||
use actix_web::middleware::{self, Logger};
|
||||
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder, ResponseError, Result};
|
||||
use actix_web::web::resource;
|
||||
use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, ResponseError, Result, post, web};
|
||||
use askama::Template;
|
||||
use pronouns_today::user_preferences::Preference;
|
||||
use pronouns_today::{InstanceSettings, Pronoun};
|
||||
|
||||
#[cfg(feature = "ogp_images")]
|
||||
use image::{DynamicImage, ImageOutputFormat};
|
||||
#[cfg(feature = "ogp_images")]
|
||||
use ogp_images::render_today;
|
||||
|
||||
// TODO: Make this configurable
|
||||
const HOSTNAME: &str = "pronouns.today";
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate<'a> {
|
||||
name: Option<String>,
|
||||
pronoun: &'a Pronoun,
|
||||
pronouns: Vec<(usize, &'a Pronoun)>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option<String>) -> String {
|
||||
fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option<String>, url: String) -> String {
|
||||
IndexTemplate {
|
||||
name,
|
||||
pronoun,
|
||||
pronouns: settings.pronoun_list.iter().enumerate().collect(),
|
||||
url,
|
||||
}
|
||||
.render()
|
||||
.unwrap()
|
||||
|
@ -53,40 +67,67 @@ async fn create_link(
|
|||
.finish())
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn default(settings: web::Data<InstanceSettings>) -> Result<impl Responder> {
|
||||
let pronoun = settings
|
||||
.select_pronouns(None, None)
|
||||
.map_err(|_| Error::InvlaidPrefString)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(render_page(&pronoun, &settings, None)))
|
||||
fn form_full_url(host: &str, name: Option<&str>, prefstr: Option<&str>) -> String {
|
||||
["https:/", host].into_iter()
|
||||
.chain(name)
|
||||
.chain(prefstr)
|
||||
.collect::<Vec<&str>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[get("/{prefs}")]
|
||||
async fn only_prefs(
|
||||
web::Path(prefs): web::Path<String>,
|
||||
settings: web::Data<InstanceSettings>,
|
||||
) -> Result<impl Responder> {
|
||||
/// Determine some basic information about a request
|
||||
///
|
||||
/// Determines the name and prefstring properties, if available, and also computes the pronoun that
|
||||
/// should be responded using. This method is designed to facilitate creating the basic response
|
||||
/// pages.
|
||||
///
|
||||
/// Both arguments should be passed directly from the caller, and the return values are, in order,
|
||||
/// the prefstring, if available, the user's name, if available, and the computed pronouns.
|
||||
fn get_request_info<'s, 'r>(
|
||||
settings: &'s web::Data<InstanceSettings>,
|
||||
req: &'r HttpRequest,
|
||||
) -> (Option<&'r str>, Option<&'r str>, Result<&'s Pronoun, Error>) {
|
||||
let prefs = req.match_info().get("prefs");
|
||||
let name = req.match_info().get("name");
|
||||
let pronoun = settings
|
||||
.select_pronouns(None, Some(&prefs))
|
||||
.map_err(|_| Error::InvlaidPrefString)?;
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(render_page(&pronoun, &settings, None)))
|
||||
.select_pronouns(name, prefs)
|
||||
.map_err(|_| Error::InvlaidPrefString);
|
||||
(prefs, name, pronoun)
|
||||
}
|
||||
|
||||
#[get("/{name}/{prefs}")]
|
||||
async fn prefs_and_name(
|
||||
web::Path((name, prefs)): web::Path<(String, String)>,
|
||||
async fn handle_basic_request(
|
||||
settings: web::Data<InstanceSettings>,
|
||||
req: HttpRequest,
|
||||
) -> Result<impl Responder> {
|
||||
let pronoun = settings
|
||||
.select_pronouns(Some(&name), Some(&prefs))
|
||||
.map_err(|_| Error::InvlaidPrefString)?;
|
||||
|
||||
let (prefstr, name, pronoun) = get_request_info(&settings, &req);
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(render_page(&pronoun, &settings, Some(name))))
|
||||
.body(render_page(
|
||||
pronoun?,
|
||||
&settings,
|
||||
name.map(str::to_owned),
|
||||
form_full_url(HOSTNAME, name, prefstr)
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ogp_images")]
|
||||
async fn handle_thumbnail_request(
|
||||
settings: web::Data<InstanceSettings>,
|
||||
req: HttpRequest,
|
||||
) -> Result<impl Responder> {
|
||||
|
||||
let (_, name, pronoun) = get_request_info(&settings, &req);
|
||||
|
||||
let mut data: Vec<u8> = Vec::with_capacity(15_000);
|
||||
let image = DynamicImage::ImageRgb8(render_today(pronoun?, name.unwrap_or("")));
|
||||
image.write_to(&mut data, ImageOutputFormat::Png)
|
||||
.expect("Error encoding thumbnail to PNG");
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("image/png")
|
||||
.body(data))
|
||||
}
|
||||
|
||||
async fn not_found() -> impl Responder {
|
||||
|
@ -101,13 +142,21 @@ async fn main() -> std::io::Result<()> {
|
|||
println!("Starting pronouns-today-web on 127.0.0.1:8080");
|
||||
HttpServer::new(|| {
|
||||
let logger = Logger::default();
|
||||
App::new()
|
||||
let app = App::new()
|
||||
.data(InstanceSettings::default())
|
||||
.wrap(logger)
|
||||
.wrap(middleware::NormalizePath::new(TrailingSlash::Trim))
|
||||
.service(prefs_and_name)
|
||||
.service(only_prefs)
|
||||
.service(default)
|
||||
.wrap(middleware::NormalizePath::new(TrailingSlash::Trim));
|
||||
|
||||
#[cfg(feature = "ogp_images")]
|
||||
let app = app
|
||||
.service(resource("/thumb.png") .to(handle_thumbnail_request))
|
||||
.service(resource("/{prefs}/thumb.png") .to(handle_thumbnail_request))
|
||||
.service(resource("/{name}/{prefs}/thumb.png").to(handle_thumbnail_request));
|
||||
|
||||
app
|
||||
.service(resource("/") .to(handle_basic_request))
|
||||
.service(resource("/{prefs}") .to(handle_basic_request))
|
||||
.service(resource("/{name}/{prefs}").to(handle_basic_request))
|
||||
.service(create_link)
|
||||
.default_service(web::to(not_found))
|
||||
})
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
#![cfg(feature = "ogp_images")]
|
||||
|
||||
use crate::contrast::generate_color_pair_rgba;
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, Pixel, Rgb, Rgba};
|
||||
use lazy_static::lazy_static;
|
||||
use pronouns_today::{Pronoun, util::{self, pcg64_seed, seed_with_today}};
|
||||
use rusttype::{Font, Scale, point};
|
||||
|
||||
const SCALE: u32 = 400;
|
||||
const FONT_SCALE: f32 = 250.0;
|
||||
const FONT_DATA: &[u8] = include_bytes!("../assets/LeagueGothic.otf");
|
||||
lazy_static! {
|
||||
static ref FONT: Font<'static> = Font::try_from_bytes(FONT_DATA)
|
||||
.expect("Invalid font data in build. This build and binary is invalid, and cannot be used.");
|
||||
}
|
||||
|
||||
pub fn render_today(pronouns: &Pronoun, name: &str) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
|
||||
let mut state = util::INITIAL_STATE;
|
||||
|
||||
seed_with_today(&mut state);
|
||||
pcg64_seed(&mut state, name.as_bytes());
|
||||
|
||||
render(pronouns, &mut state)
|
||||
}
|
||||
pub fn render(pronouns: &Pronoun, pcg64: &mut u128) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
|
||||
let (color1, color2) = generate_color_pair_rgba(pcg64);
|
||||
|
||||
render_with_colors(pronouns, color1, color2)
|
||||
}
|
||||
|
||||
pub fn render_with_colors(pronouns: &Pronoun, color1: Rgba<u8>, color2: Rgba<u8>) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
|
||||
|
||||
let scale = Scale::uniform(FONT_SCALE);
|
||||
|
||||
let mut output_image = ImageBuffer::from_fn(SCALE * 2, SCALE, |x, _| {
|
||||
if x < SCALE {
|
||||
color1
|
||||
} else {
|
||||
color2
|
||||
}
|
||||
});
|
||||
|
||||
for (color, text, offset) in [(color2, &pronouns.subject_pronoun, 0), (color1, &pronouns.object_pronoun, 400)] {
|
||||
let v_metrics = FONT.v_metrics(scale);
|
||||
let glyphs: Vec<_> = FONT
|
||||
.layout(&text.to_uppercase(), scale, point(0.0, v_metrics.ascent))
|
||||
.collect();
|
||||
let min_x = glyphs
|
||||
.first()
|
||||
.map(|g| g.pixel_bounding_box().unwrap().min.x)
|
||||
.unwrap_or(0);
|
||||
let max_x = glyphs
|
||||
.last()
|
||||
.map(|g| g.pixel_bounding_box().unwrap().max.x)
|
||||
.unwrap_or(0);
|
||||
let width = max_x - min_x;
|
||||
let height = v_metrics.ascent - v_metrics.descent;
|
||||
|
||||
// Recompute offset to center text on page
|
||||
let x_offset = (SCALE as u32 - width as u32) / 2 + offset;
|
||||
let y_offset = ((SCALE as f32 - height) / 2.0) as u32;
|
||||
|
||||
// Draw glyphs onto the buffer
|
||||
for glyph in glyphs {
|
||||
if let Some(bounding_box) = glyph.pixel_bounding_box() {
|
||||
glyph.draw(|x, y, v| {
|
||||
if v > 0.0 {
|
||||
|
||||
let mut overlay_color = color;
|
||||
let alpha = &mut overlay_color.channels_mut()[3];
|
||||
*alpha = (*alpha as f32 * v) as u8;
|
||||
|
||||
output_image.get_pixel_mut(
|
||||
x + x_offset + bounding_box.min.x as u32,
|
||||
y + y_offset + bounding_box.min.y as u32
|
||||
).blend(&overlay_color);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DynamicImage::ImageRgba8(output_image).into_rgb8()
|
||||
}
|
|
@ -2,6 +2,24 @@
|
|||
<head>
|
||||
<title>{% if name.is_some() %} {{ name.as_ref().unwrap() }}'s {% else %}
|
||||
My{% endif %} Pronouns Today</title>
|
||||
|
||||
<!-- OGP Tags -->
|
||||
{% if name.is_some() %}
|
||||
<meta property="og:title" content="{{ name.as_ref().unwrap() }}'s Pronouns Today" />
|
||||
{% else %}
|
||||
<meta property="og:title" content="Random Daily Pronouns" />
|
||||
{% endif %}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:site_name" content="Pronouns.Today" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ url }}" />
|
||||
<meta property="og:image:url" content="{{ url }}/thumb.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="800" />
|
||||
<meta property="og:image:height" content="400" />
|
||||
<meta property="og:image:alt" content="The pronouns {{pronoun.subject_pronoun | capitalize}}/{{pronoun.object_pronoun | capitalize}}" />
|
||||
<meta property="og:description" content="{{ name.as_ref().map(String::as_str).unwrap_or("Whoever posted this") }} uses any pronouns, but for today, {{pronoun.subject_pronoun}} would appreciate it if you used {{pronoun.render_threeform()}} pronouns for {{pronoun.object_pronoun}}." />
|
||||
|
||||
<style>
|
||||
#form {
|
||||
display: none;
|
||||
|
|
Loading…
Reference in New Issue