Merge branch 'develop' of codeberg.org:calckey/calckey into develop

This commit is contained in:
ThatOneCalculator 2023-06-02 14:52:58 -07:00
commit 088ff4c251
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
12 changed files with 135 additions and 68 deletions

View File

@ -11,7 +11,7 @@
- Federate with note edits - Federate with note edits
- User "choices" (recommended users) like Mastodon and Soapbox - User "choices" (recommended users) like Mastodon and Soapbox
- Join Reason system like Mastodon/Pleroma - Join Reason system like Mastodon/Pleroma
- Option to publicize instance blocks - Option to publicize server blocks
- Build flag to remove NSFW/AI stuff - Build flag to remove NSFW/AI stuff
- Filter notifications by user - Filter notifications by user
- Exclude self from antenna - Exclude self from antenna
@ -19,7 +19,7 @@
- MFM button - MFM button
- Personal notes for all accounts - Personal notes for all accounts
- Fully revamp non-logged-in screen - Fully revamp non-logged-in screen
- Lookup/details for post/file/instance - Lookup/details for post/file/server
- [Rat mode?](https://stop.voring.me/notes/933fx97bmd) - [Rat mode?](https://stop.voring.me/notes/933fx97bmd)
## Work in progress ## Work in progress
@ -43,7 +43,7 @@
- Upgrade packages with security vunrabilities - Upgrade packages with security vunrabilities
- Saner defaults - Saner defaults
- Fediverse account migration - Fediverse account migration
- Recommended instances timeline - Recommended servers timeline
- OCR image captioning - OCR image captioning
- Improve mobile UX - Improve mobile UX
- Swipe through pages on mobile - Swipe through pages on mobile
@ -71,7 +71,7 @@
- Better welcome screen (not logged in) - Better welcome screen (not logged in)
- vue-plyr as video/audio player - vue-plyr as video/audio player
- Ability to turn off "Connection lost" message - Ability to turn off "Connection lost" message
- Raw instance info only for moderators - Raw server info only for moderators
- New spinner animation - New spinner animation
- Spinner instead of "Loading..." - Spinner instead of "Loading..."
- SearchX instead of Google - SearchX instead of Google
@ -98,7 +98,7 @@
- Obliteration of Ai-chan - Obliteration of Ai-chan
- Switch to [Calckey.js](https://codeberg.org/calckey/calckey.js) - Switch to [Calckey.js](https://codeberg.org/calckey/calckey.js)
- Woozy mode 🥴 - Woozy mode 🥴
- Improve blocking instances - Improve blocking servers
- Release notes - Release notes
- New post style - New post style
- Admins set default reaction emoji - Admins set default reaction emoji
@ -117,7 +117,7 @@
- Sonic search - Sonic search
- Popular color schemes, including Nord, Gruvbox, and Catppuccin - Popular color schemes, including Nord, Gruvbox, and Catppuccin
- Non-nyaify cat mode - Non-nyaify cat mode
- Post imports from other Calckey/Misskey/Mastodon/Pleroma/Akkoma instances - Post imports from other Calckey/Misskey/Mastodon/Pleroma/Akkoma servers
- Improve Classic mode - Improve Classic mode
- Proper Helm/Kubernetes config - Proper Helm/Kubernetes config
- Multiple boost visibilities - Multiple boost visibilities

View File

@ -23,15 +23,15 @@
# ✨ About Calckey # ✨ About Calckey
- Calckey is based off of Misskey, a powerful microblogging server on ActivityPub with features such as emoji reactions, a customizable web UI, rich chatting, and much more! - Calckey is based off of Misskey, a powerful microblogging server on ActivityPub with features such as emoji reactions, a customizable web UI, rich chatting, and much more!
- Calckey adds many quality of life changes and bug fixes for users and instance admins alike. - Calckey adds many quality of life changes and bug fixes for users and server admins alike.
- Read **[this document](./CALCKEY.md)** all for current and future differences. - Read **[this document](./CALCKEY.md)** all for current and future differences.
- Notable differences: - Notable differences:
- Improved UI/UX (especially on mobile) - Improved UI/UX (especially on mobile)
- Improved notifications - Improved notifications
- Improved instance security - Improved server security
- Improved accessibility - Improved accessibility
- Improved threads - Improved threads
- Recommended Instances timeline - Recommended Servers timeline
- OCR image captioning - OCR image captioning
- New and improved Groups - New and improved Groups
- Better intro tutorial - Better intro tutorial
@ -50,10 +50,10 @@
- 💸 OpenCollective: <https://opencollective.com/Calckey> - 💸 OpenCollective: <https://opencollective.com/Calckey>
- 💸 Liberapay: <https://liberapay.com/ThatOneCalculator> - 💸 Liberapay: <https://liberapay.com/ThatOneCalculator>
- Donate publicly to get your name on the Patron list! - Donate publicly to get your name on the Patron list!
- 🚢 Flagship instance: <https://calckey.social> - 🚢 Flagship server: <https://calckey.social>
- 📣 Official account: <https://i.calckey.cloud/@calckey> - 📣 Official account: <https://i.calckey.cloud/@calckey>
- 💁 Matrix support room: <https://matrix.to/#/#calckey:matrix.fedibird.com> - 💁 Matrix support room: <https://matrix.to/#/#calckey:matrix.fedibird.com>
- 📜 Instance list: <https://calckey.fediverse.observer/list> - 📜 Server list: <https://calckey.fediverse.observer/list>
- 📖 JoinFediverse Wiki: <https://joinfediverse.wiki/What_is_Calckey%3F> - 📖 JoinFediverse Wiki: <https://joinfediverse.wiki/What_is_Calckey%3F>
- 🐋 Docker Hub: <https://hub.docker.com/r/thatonecalculator/calckey> - 🐋 Docker Hub: <https://hub.docker.com/r/thatonecalculator/calckey>
- ✍️ Weblate: <https://hosted.weblate.org/engage/calckey/> - ✍️ Weblate: <https://hosted.weblate.org/engage/calckey/>
@ -177,13 +177,13 @@ Please don't use ElasticSearch unless you already have an ElasticSearch setup an
## 💅 Customize ## 💅 Customize
- To add custom CSS for all users, edit `./custom/assets/instance.css`. - To add custom CSS for all users, edit `./custom/assets/instance.css`.
- To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be available on `https://yourinstance.tld/static-assets/filename.ext`. - To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be available on `https://yourserver.tld/static-assets/filename.ext`.
- To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`) - To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`)
- To add custom error images, place them in the `./custom/assets/badges` directory, replacing the files already there. - To add custom error images, place them in the `./custom/assets/badges` directory, replacing the files already there.
- To add custom sounds, place only mp3 files in the `./custom/assets/sounds` directory. - To add custom sounds, place only mp3 files in the `./custom/assets/sounds` directory.
- To update custom assets without rebuilding, just run `pnpm run gulp`. - To update custom assets without rebuilding, just run `pnpm run gulp`.
## 🧑‍🔬 Configuring a new instance ## 🧑‍🔬 Configuring a new server
- Run `cp .config/example.yml .config/default.yml` - Run `cp .config/example.yml .config/default.yml`
- Edit `.config/default.yml`, making sure to fill out required fields. - Edit `.config/default.yml`, making sure to fill out required fields.
@ -198,7 +198,7 @@ For migrating from Misskey v13, Misskey v12, and Foundkey, read [this document](
### 🍀 Nginx (recommended) ### 🍀 Nginx (recommended)
- Run `sudo cp ./calckey.nginx.conf /etc/nginx/sites-available/ && cd /etc/nginx/sites-available/` - Run `sudo cp ./calckey.nginx.conf /etc/nginx/sites-available/ && cd /etc/nginx/sites-available/`
- Edit `calckey.nginx.conf` to reflect your instance properly - Edit `calckey.nginx.conf` to reflect your server properly
- Run `sudo ln -s ./calckey.nginx.conf ../sites-enabled/calckey.nginx.conf` - Run `sudo ln -s ./calckey.nginx.conf ../sites-enabled/calckey.nginx.conf`
- Run `sudo nginx -t` to validate that the config is valid, then restart the NGINX service. - Run `sudo nginx -t` to validate that the config is valid, then restart the NGINX service.
@ -218,7 +218,7 @@ example.tld {
> Apache has some known problems with Calckey. Only use it if you have to. > Apache has some known problems with Calckey. Only use it if you have to.
- Run `sudo cp ./calckey.apache.conf /etc/apache2/sites-available/ && cd /etc/apache2/sites-available/` - Run `sudo cp ./calckey.apache.conf /etc/apache2/sites-available/ && cd /etc/apache2/sites-available/`
- Edit `calckey.apache.conf` to reflect your instance properly - Edit `calckey.apache.conf` to reflect your server properly
- Run `sudo a2ensite calckey.apache` to enable the site - Run `sudo a2ensite calckey.apache` to enable the site
- Run `sudo service apache2 restart` to reload apache2 configuration - Run `sudo service apache2 restart` to reload apache2 configuration
## 🚀 Build and launch! ## 🚀 Build and launch!

View File

@ -1,4 +1,4 @@
# 🐳 Running a Calckey instance with Docker # 🐳 Running a Calckey server with Docker
## Pre-built docker container ## Pre-built docker container
[thatonecalculator/calckey](https://hub.docker.com/r/thatonecalculator/calckey) [thatonecalculator/calckey](https://hub.docker.com/r/thatonecalculator/calckey)
@ -8,7 +8,7 @@
There is a `docker-compose.yml` in the root of the project that you can use to build the container from source There is a `docker-compose.yml` in the root of the project that you can use to build the container from source
- .config/docker.env (**db config settings**) - .config/docker.env (**db config settings**)
- .config/default.yml (**calckey instance settings**) - .config/default.yml (**calckey server settings**)
## Configuring ## Configuring
@ -20,7 +20,7 @@ Rename the files:
then edit them according to your environment. then edit them according to your environment.
You can configure `docker.env` with anything you like, but you will have to pay attention to the `default.yml` file: You can configure `docker.env` with anything you like, but you will have to pay attention to the `default.yml` file:
- `url` should be set to the URL you will be hosting the web interface for the instance at. - `url` should be set to the URL you will be hosting the web interface for the server at.
- `host`, `db`, `user`, `pass` will have to be configured in the `PostgreSQL configuration` section - `host` is the name of the postgres container (eg: *calckey_db_1*), and the others should match your `docker.env`. - `host`, `db`, `user`, `pass` will have to be configured in the `PostgreSQL configuration` section - `host` is the name of the postgres container (eg: *calckey_db_1*), and the others should match your `docker.env`.
- `host`will need to be configured in the *Redis configuration* section - it is the name of the redis container (eg: *calckey_redis_1*) - `host`will need to be configured in the *Redis configuration* section - it is the name of the redis container (eg: *calckey_redis_1*)
- `auth` will need to be configured in the *Sonic* section - cannot be the default `SecretPassword` - `auth` will need to be configured in the *Sonic* section - cannot be the default `SecretPassword`
@ -36,7 +36,7 @@ Copy `docker-compose.yml` and the `config/` to a directory, then run the **docke
NOTE: This will take some time to come fully online, even after download and extracting the container images, and it may emit some error messages before completing successfully. Specifically, the `db` container needs to initialize and so isn't available to the `web` container right away. Only once the `db` container comes online does the `web` container start building and initializing the calckey tables. NOTE: This will take some time to come fully online, even after download and extracting the container images, and it may emit some error messages before completing successfully. Specifically, the `db` container needs to initialize and so isn't available to the `web` container right away. Only once the `db` container comes online does the `web` container start building and initializing the calckey tables.
Once the instance is up you can use a web browser to access the web interface at `http://serverip:3000` (where `serverip` is the IP of the server you are running the calckey instance on). Once the server is up you can use a web browser to access the web interface at `http://serverip:3000` (where `serverip` is the IP of the server you are running the calckey server on).
## Docker for development ## Docker for development

View File

@ -1,4 +1,4 @@
# Running a Calckey instance with Kubernetes and Helm # Running a Calckey server with Kubernetes and Helm
This is a [Helm](https://helm.sh/) chart directory in the root of the project This is a [Helm](https://helm.sh/) chart directory in the root of the project
that you can use to deploy calckey to a Kubernetes cluster that you can use to deploy calckey to a Kubernetes cluster
@ -27,7 +27,7 @@ helm upgrade \
-f .config/helm_values.yml -f .config/helm_values.yml
``` ```
4. Watch your calckey instance spin up: 4. Watch your calckey server spin up:
```shell ```shell
kubectl -n calckey get po -w kubectl -n calckey get po -w
``` ```

View File

@ -598,6 +598,8 @@ scratchpadDescription: "The scratchpad provides an environment for AiScript expe
output: "Output" output: "Output"
script: "Script" script: "Script"
disablePagesScript: "Disable AiScript on Pages" disablePagesScript: "Disable AiScript on Pages"
expandOnNoteClick: "Open post on click"
expandOnNoteClickDesc: "If disabled, you can still open posts in the right-click menu or by clicking the timestamp."
updateRemoteUser: "Update remote user information" updateRemoteUser: "Update remote user information"
deleteAllFiles: "Delete all files" deleteAllFiles: "Delete all files"
deleteAllFilesConfirm: "Are you sure that you want to delete all files?" deleteAllFilesConfirm: "Are you sure that you want to delete all files?"

View File

@ -11,6 +11,7 @@ import { addFile } from "@/services/drive/add-file.js";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { db } from "@/db/postgre.js"; import { db } from "@/db/postgre.js";
import probeImageSize from "probe-image-size"; import probeImageSize from "probe-image-size";
import * as path from "path";
const logger = queueLogger.createSubLogger("import-custom-emojis"); const logger = queueLogger.createSubLogger("import-custom-emojis");
@ -29,11 +30,11 @@ export async function importCustomEmojis(
return; return;
} }
const [path, cleanup] = await createTempDir(); const [tempPath, cleanup] = await createTempDir();
logger.info(`Temp dir is ${path}`); logger.info(`Temp dir is ${tempPath}`);
const destPath = `${path}/emojis.zip`; const destPath = `${tempPath}/emojis.zip`;
try { try {
fs.writeFileSync(destPath, "", "binary"); fs.writeFileSync(destPath, "", "binary");
@ -46,44 +47,96 @@ export async function importCustomEmojis(
throw e; throw e;
} }
const outputPath = `${path}/emojis`; const outputPath = `${tempPath}/emojis`;
const unzipStream = fs.createReadStream(destPath); const unzipStream = fs.createReadStream(destPath);
const zip = new AdmZip(destPath); const zip = new AdmZip(destPath);
zip.extractAllToAsync(outputPath, true, false, async (error) => { zip.extractAllToAsync(outputPath, true, false, async (error) => {
if (error) throw error; if (error) throw error;
const metaRaw = fs.readFileSync(`${outputPath}/meta.json`, "utf-8");
const meta = JSON.parse(metaRaw);
for (const record of meta.emojis) { if (fs.existsSync(`${outputPath}/meta.json`)) {
if (!record.downloaded) continue; logger.info("starting emoji import with metadata");
const emojiInfo = record.emoji; const metaRaw = fs.readFileSync(`${outputPath}/meta.json`, "utf-8");
const emojiPath = `${outputPath}/${record.fileName}`; const meta = JSON.parse(metaRaw);
await Emojis.delete({
name: emojiInfo.name, for (const record of meta.emojis) {
}); if (!record.downloaded) continue;
const driveFile = await addFile({ const emojiInfo = record.emoji;
user: null, const emojiPath = `${outputPath}/${record.fileName}`;
path: emojiPath, await Emojis.delete({
name: record.fileName, name: emojiInfo.name,
force: true, });
}); const driveFile = await addFile({
const file = fs.createReadStream(emojiPath); user: null,
const size = await probeImageSize(file); path: emojiPath,
file.destroy(); name: record.fileName,
await Emojis.insert({ force: true,
id: genId(), });
updatedAt: new Date(), const file = fs.createReadStream(emojiPath);
name: emojiInfo.name, const size = await probeImageSize(file);
category: emojiInfo.category, file.destroy();
host: null, await Emojis.insert({
aliases: emojiInfo.aliases, id: genId(),
originalUrl: driveFile.url, updatedAt: new Date(),
publicUrl: driveFile.webpublicUrl ?? driveFile.url, name: emojiInfo.name,
type: driveFile.webpublicType ?? driveFile.type, category: emojiInfo.category,
license: emojiInfo.license, host: null,
width: size.width || null, aliases: emojiInfo.aliases,
height: size.height || null, originalUrl: driveFile.url,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type,
license: emojiInfo.license,
width: size.width || null,
height: size.height || null,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
}
} else {
logger.info("starting emoji import without metadata");
// Since we lack metadata, we import into a randomized category name instead
let categoryName = genId();
let containedEmojis = fs.readdirSync(outputPath);
// Filter out accidental JSON files
containedEmojis = containedEmojis.filter(
(emoji) => !emoji.match(/\.(json)$/i),
);
for (const emojiFilename of containedEmojis) {
// strip extension and get filename to use as name
const name = path.basename(emojiFilename, path.extname(emojiFilename));
const emojiPath = `${outputPath}/${emojiFilename}`;
logger.info(`importing ${name}`);
await Emojis.delete({
name: name,
});
const driveFile = await addFile({
user: null,
path: emojiPath,
name: path.basename(emojiFilename),
force: true,
});
const file = fs.createReadStream(emojiPath);
const size = await probeImageSize(file);
file.destroy();
logger.info(`emoji size: ${size.width}x${size.height}`);
await Emojis.insert({
id: genId(),
updatedAt: new Date(),
name: name,
category: categoryName,
host: null,
aliases: [],
originalUrl: driveFile.url,
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type,
license: null,
width: size.width || null,
height: size.height || null,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
}
} }
await db.queryResultCache!.remove(["meta_emojis"]); await db.queryResultCache!.remove(["meta_emojis"]);

View File

@ -68,6 +68,7 @@
class="article" class="article"
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
@click="noteClick" @click="noteClick"
:style="{ cursor: expandOnNoteClick && !detailedView ? 'pointer' : '' }"
> >
<div class="main"> <div class="main">
<div class="header-container"> <div class="header-container">
@ -313,6 +314,7 @@ const muted = ref(getWordSoftMute(note, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
const keymap = { const keymap = {
r: () => reply(true), r: () => reply(true),
@ -501,7 +503,7 @@ function scrollIntoView() {
} }
function noteClick(e) { function noteClick(e) {
if (document.getSelection().type === "Range" || props.detailedView) { if (document.getSelection().type === "Range" || props.detailedView || !expandOnNoteClick) {
e.stopPropagation(); e.stopPropagation();
} else { } else {
router.push(notePage(appearNote)); router.push(notePage(appearNote));
@ -704,7 +706,6 @@ defineExpose({
position: relative; position: relative;
overflow: clip; overflow: clip;
padding: 4px 32px 10px; padding: 4px 32px 10px;
cursor: pointer;
&:first-child, &:first-child,
&:nth-child(2) { &:nth-child(2) {

View File

@ -534,12 +534,8 @@ onUnmounted(() => {
> .reply { > .reply {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
cursor: pointer;
padding-top: 24px; padding-top: 24px;
padding-bottom: 10px; padding-bottom: 10px;
@media (pointer: coarse) {
cursor: default;
}
} }
// Hover // Hover

View File

@ -14,7 +14,10 @@
@contextmenu.stop="onContextmenu" @contextmenu.stop="onContextmenu"
> >
<div v-if="conversation && depth > 1" class="line"></div> <div v-if="conversation && depth > 1" class="line"></div>
<div class="main" @click="noteClick"> <div class="main"
@click="noteClick"
:style="{ cursor: expandOnNoteClick ? 'pointer' : '' }"
>
<div class="avatar-container"> <div class="avatar-container">
<MkAvatar class="avatar" :user="appearNote.user" /> <MkAvatar class="avatar" :user="appearNote.user" />
<div <div
@ -258,6 +261,7 @@ const replies: misskey.entities.Note[] =
) )
.reverse() ?? []; .reverse() ?? [];
const enableEmojiReactions = defaultStore.state.enableEmojiReactions; const enableEmojiReactions = defaultStore.state.enableEmojiReactions;
const expandOnNoteClick = defaultStore.state.expandOnNoteClick;
useNoteCapture({ useNoteCapture({
rootEl: el, rootEl: el,
@ -397,7 +401,7 @@ function blur() {
} }
function noteClick(e) { function noteClick(e) {
if (document.getSelection().type === "Range") { if (document.getSelection().type === "Range" || !expandOnNoteClick) {
e.stopPropagation(); e.stopPropagation();
} else { } else {
router.push(notePage(props.note)); router.push(notePage(props.note));
@ -422,7 +426,6 @@ function noteClick(e) {
> .main { > .main {
display: flex; display: flex;
cursor: pointer;
> .avatar-container { > .avatar-container {
margin-right: 8px; margin-right: 8px;

View File

@ -168,10 +168,10 @@
<i class="ph-stop ph-bold"></i> {{ i18n.ts._mfm.stop }} <i class="ph-stop ph-bold"></i> {{ i18n.ts._mfm.stop }}
</template> </template>
</MkButton> </MkButton>
<div <!-- <div
v-if="(isLong && !collapsed) || (props.note.cw && showContent)" v-if="(isLong && !collapsed) || (props.note.cw && showContent)"
class="fade" class="fade"
></div> ></div> -->
</div> </div>
</template> </template>

View File

@ -54,6 +54,10 @@
<FormSwitch v-model="disablePagesScript" class="_formBlock">{{ <FormSwitch v-model="disablePagesScript" class="_formBlock">{{
i18n.ts.disablePagesScript i18n.ts.disablePagesScript
}}</FormSwitch> }}</FormSwitch>
<FormSwitch v-model="expandOnNoteClick" class="_formBlock">{{
i18n.ts.expandOnNoteClick
}}<template #caption>{{ i18n.ts.expandOnNoteClickDesc }}</template>
</FormSwitch>
<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock" <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock"
>{{ i18n.ts.flagShowTimelineReplies >{{ i18n.ts.flagShowTimelineReplies
}}<template #caption }}<template #caption
@ -299,6 +303,9 @@ const nsfw = computed(defaultStore.makeGetterSetter("nsfw"));
const disablePagesScript = computed( const disablePagesScript = computed(
defaultStore.makeGetterSetter("disablePagesScript") defaultStore.makeGetterSetter("disablePagesScript")
); );
const expandOnNoteClick = computed(
defaultStore.makeGetterSetter("expandOnNoteClick")
);
const showFixedPostForm = computed( const showFixedPostForm = computed(
defaultStore.makeGetterSetter("showFixedPostForm") defaultStore.makeGetterSetter("showFixedPostForm")
); );
@ -366,6 +373,7 @@ watch(
seperateRenoteQuote, seperateRenoteQuote,
showAdminUpdates, showAdminUpdates,
autoplayMfm, autoplayMfm,
expandOnNoteClick,
], ],
async () => { async () => {
await reloadAsk(); await reloadAsk();

View File

@ -162,6 +162,10 @@ export const defaultStore = markRaw(
where: "device", where: "device",
default: true, default: true,
}, },
expandOnNoteClick: {
where: "device",
default: true,
},
nsfw: { nsfw: {
where: "device", where: "device",
default: "respect" as "respect" | "force" | "ignore", default: "respect" as "respect" | "force" | "ignore",