Merge branch 'develop' into merge/2024-02-03

# Conflicts:
#	locales/index.d.ts
#	packages/backend/src/core/entities/UserEntityService.ts
#	packages/frontend/src/_dev_boot_.ts
#	packages/misskey-js/src/autogen/types.ts
#	sharkey-locales/en-US.yml
This commit is contained in:
Hazelnoot 2025-02-07 11:54:29 -05:00
commit f36029f795
24 changed files with 512 additions and 35 deletions

View file

@ -3,10 +3,12 @@
# **What does this MR do?**
<!-- Please give us a brief description of what this PR does. -->
%{all_commits}
# **Contribution Guidelines**
By submitting this merge request, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
- [ ] I agree to follow this project's Contribution Guidelines
- [ ] I have made sure to test this merge request
<!-- Uncomment if your merge request has multiple authors -->
<!-- Co-authored-by: Name <email@email.com> -->
<!-- %{co_authored_by} -->

34
locales/index.d.ts vendored
View file

@ -11977,6 +11977,40 @@ export interface Locale extends ILocale {
* Adding entries here will override the default robots.txt packaged with Sharkey.
*/
"robotsTxtDescription": string;
/**
* Default content warning for new posts
*/
"defaultCW": string;
/**
* The value here will be auto-filled as the content warning for all new posts and replies.
*/
"defaultCWDescription": string;
/**
* Automatic CW priority
*/
"defaultCWPriority": string;
/**
* Select preferred action when default CW and keep CW settings are both enabled at the same time.
*/
"defaultCWPriorityDescription": string;
"_defaultCWPriority": {
/**
* Use Default (use the default CW, ignoring the inherited CW)
*/
"default": string;
/**
* Use Parent (use the inherited CW, ignoring the default CW)
*/
"parent": string;
/**
* Use Default, then Parent (use the default CW, and append the inherited CW)
*/
"defaultParent": string;
/**
* Use Parent, then Default (use the inherited CW, and append the default CW)
*/
"parentDefault": string;
};
/**
* ID
*/

View file

@ -0,0 +1,11 @@
export class AddUserProfileDefaultCw1738446745738 {
name = 'AddUserProfileDefaultCw1738446745738'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw"`);
}
}

View file

@ -0,0 +1,13 @@
export class AddUserProfileDefaultCwPriority1738468079662 {
name = 'AddUserProfileDefaultCwPriority1738468079662'
async up(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."user_profile_default_cw_priority_enum" AS ENUM ('default', 'parent', 'defaultParent', 'parentDefault')`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw_priority" "public"."user_profile_default_cw_priority_enum" NOT NULL DEFAULT 'parent'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw_priority"`);
await queryRunner.query(`DROP TYPE "public"."user_profile_default_cw_priority_enum"`);
}
}

View file

@ -263,6 +263,67 @@ export class MfmService {
break;
}
case 'rp': break;
case 'rt': {
appendChildren(node.childNodes);
break;
}
case 'ruby': {
if (node.childNodes) {
/*
we get:
```
<ruby>
some text <rp>(</rp> <rt>annotation</rt> <rp>)</rp>
more text <rt>more annotation<rt>
</ruby>
```
and we want to produce:
```
$[ruby $[group some text] annotation]
$[ruby $[group more text] more annotation]
```
that `group` is a hack, because when the `ruby` render
sees just text inside the `$[ruby]`, it splits on
whitespace, considers the first "word" to be the main
content, and the rest the annotation
with that `group`, we force it to consider the whole
group as the main content
(note that the `rp` are to be ignored, they only exist
for browsers who don't understand ruby)
*/
let nonRtNodes = [];
// scan children, ignore `rp`, split on `rt`
for (const child of node.childNodes) {
if (treeAdapter.isTextNode(child)) {
nonRtNodes.push(child);
continue;
}
if (!treeAdapter.isElementNode(child)) {
continue;
}
if (child.nodeName === 'rp') {
continue;
}
if (child.nodeName === 'rt') {
text += '$[ruby $[group ';
appendChildren(nonRtNodes);
text += '] ';
analyze(child);
text += '] ';
nonRtNodes = [];
continue;
}
nonRtNodes.push(child);
}
}
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
@ -381,6 +442,14 @@ export class MfmService {
}
}
// hack for ruby, should never be needed because we should
// never send this out to other instances
case 'group': {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
}
default: {
return fnDefault(node);
}
@ -559,11 +628,65 @@ export class MfmService {
},
async fn(node) {
const el = doc.createElement('span');
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
switch (node.props.name) {
case 'group': { // hack for ruby
const el = doc.createElement('span');
await appendChildren(node.children, el);
return el;
}
case 'ruby': {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
const el = doc.createElement('span');
await appendChildren(node.children, el);
return el;
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
await appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
}
}
default: {
const el = doc.createElement('span');
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
}
}
},
blockCode(node) {

View file

@ -55,6 +55,8 @@ import type { NoteEntityService } from './NoteEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const Ajv = _Ajv.default;
const ajv = new Ajv();
@ -669,6 +671,8 @@ export class UserEntityService implements OnModuleInit {
achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length,
policies: this.roleService.getUserPolicies(user.id),
defaultCW: profile!.defaultCW,
defaultCWPriority: profile!.defaultCWPriority,
} : {}),
...(opts.includeSecrets ? {

View file

@ -4,7 +4,7 @@
*/
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js';
import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiPage } from './Page.js';
@ -36,10 +36,10 @@ export class MiUserProfile {
})
public birthday: string | null;
@Column("varchar", {
@Column('varchar', {
length: 128,
nullable: true,
comment: "The ListenBrainz username of the User.",
comment: 'The ListenBrainz username of the User.',
})
public listenbrainz: string | null;
@ -290,6 +290,19 @@ export class MiUserProfile {
unlockedAt: number;
}[];
@Column('text', {
name: 'default_cw',
nullable: true,
})
public defaultCW: string | null;
@Column('enum', {
name: 'default_cw_priority',
enum: defaultCWPriorities,
default: 'parent',
})
public defaultCWPriority: typeof defaultCWPriorities[number];
//#region Denormalized fields
@Index()
@Column('varchar', {

View file

@ -752,6 +752,15 @@ export const packedMeDetailedOnlySchema = {
},
},
//#endregion
defaultCW: {
type: 'string',
nullable: true, optional: false,
},
defaultCWPriority: {
type: 'string',
enum: ['default', 'parent', 'defaultParent', 'parentDefault'],
nullable: false, optional: false,
},
},
} as const;

View file

@ -13,10 +13,11 @@ export const meta = {
requireCredential: false,
// 2 calls per second
// Up to 10 calls, then 4 / second.
// This allows for reliable automation.
limit: {
duration: 1000,
max: 2,
max: 10,
dripRate: 250,
},
} as const;

View file

@ -31,10 +31,12 @@ export const meta = {
},
},
// 3 calls per second
// up to 20 calls, then 1 per second.
// This handles bursty traffic when all tabs reload as a group
limit: {
duration: 1000,
max: 3,
max: 20,
dripSize: 1,
dripRate: 1000,
},
} as const;

View file

@ -133,6 +133,12 @@ export const meta = {
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
httpStatusCode: 422,
},
maxCwLength: {
message: 'You tried setting a default content warning which is too long.',
code: 'MAX_CW_LENGTH',
id: '7004c478-bda3-4b4f-acb2-4316398c9d52',
},
},
res: {
@ -243,6 +249,12 @@ export const paramDef = {
uniqueItems: true,
items: { type: 'string' },
},
defaultCW: { type: 'string', nullable: true },
defaultCWPriority: {
type: 'string',
enum: ['default', 'parent', 'defaultParent', 'parentDefault'],
nullable: false,
},
},
} as const;
@ -494,6 +506,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null;
}
let defaultCW = ps.defaultCW;
if (defaultCW !== undefined) {
if (defaultCW === '') defaultCW = null;
if (defaultCW && defaultCW.length > this.config.maxCwLength) {
throw new ApiError(meta.errors.maxCwLength);
}
profileUpdates.defaultCW = defaultCW;
}
if (ps.defaultCWPriority !== undefined) {
profileUpdates.defaultCWPriority = ps.defaultCWPriority;
}
//#region emojis/tags
let emojis = [] as string[];

View file

@ -57,10 +57,10 @@ export const meta = {
},
},
// 5 calls per 2 seconds
// up to 50 calls @ 4 per second
limit: {
duration: 1000 * 2,
max: 5,
max: 50,
dripRate: 250,
},
} as const;

View file

@ -48,7 +48,7 @@
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]);
// Fallback
if (lang == null) lang = 'en-US';

View file

@ -39,7 +39,7 @@
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]);
// Fallback
if (lang == null) lang = 'en-US';

View file

@ -58,6 +58,8 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
export const followingVisibilities = ['public', 'followers', 'private'] as const;
export const followersVisibilities = ['public', 'followers', 'private'] as const;
export const defaultCWPriorities = ['default', 'parent', 'defaultParent', 'parentDefault'] as const;
/**
*
*

View file

@ -45,6 +45,50 @@ describe('MfmService', () => {
const output = '<p><pre><code>&lt;p&gt;Hello, world!&lt;/p&gt;</code></pre></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('ruby', () => {
const input = '$[ruby some text ignore me]';
const output = '<p><ruby>some<rp>(</rp><rt>text</rt><rp>)</rp></ruby></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('ruby2', () => {
const input = '$[ruby *some text* ignore me]';
const output = '<p><ruby><i>some text</i><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
test('ruby 3', () => {
const input = '$[ruby $[group *some* text] ignore me]';
const output = '<p><ruby><span><i>some</i> text</span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
assert.equal(mfmService.toHtml(mfm.parse(input)), output);
});
});
describe('toMastoApiHtml', () => {
test('br', async () => {
const input = 'foo\nbar\nbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output);
});
test('br alt', async () => {
const input = 'foo\r\nbar\rbaz';
const output = '<p><span>foo<br>bar<br>baz</span></p>';
assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output);
});
test('escape', async () => {
const input = '```\n<p>Hello, world!</p>\n```';
const output = '<p><pre><code>&lt;p&gt;Hello, world!&lt;/p&gt;</code></pre></p>';
assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output);
});
test('ruby', async () => {
const input = '$[ruby $[group *some* text] ignore me]';
const output = '<p><ruby><span><span>*some*</span><span> text</span></span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>';
assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output);
});
});
describe('fromHtml', () => {
@ -133,5 +177,12 @@ describe('MfmService', () => {
test('hashtag', () => {
assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d');
});
test('ruby', () => {
assert.deepStrictEqual(
mfmService.fromHtml('<ruby> <i>some</i> text <rp>(</rp><rt>ignore me</rt><rp>)</rp> and <rt>more</rt></ruby>'),
'$[ruby $[group <i>some</i> text ] ignore me] $[ruby $[group and ] more]'
);
});
});
});

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<button
class="_button"
:class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing || hasPendingFollowRequestFromYou, [$style.full]: full, [$style.large]: large }]"
:disabled="wait"
:disabled="wait || disabled"
@click="onClick"
>
<template v-if="!wait">
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import * as os from '@/os.js';
@ -51,13 +51,16 @@ const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed,
full?: boolean,
large?: boolean,
disabled?: boolean,
}>(), {
full: false,
large: false,
disabled: false,
});
const emit = defineEmits<{
(_: 'update:user', value: Misskey.entities.UserDetailed): void
(_: 'update:user', value: Misskey.entities.UserDetailed): void,
(_: 'update:wait', value: boolean): void,
}>();
const isFollowing = ref(props.user.isFollowing);
@ -65,6 +68,9 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false);
const connection = useStream().useChannel('main');
// Emit the "wait" status so external components can synchronize state
watch(wait, value => emit('update:wait', value));
if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', {
userId: props.user.id,

View file

@ -122,6 +122,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
<div v-if="full" :class="$style.followRequestCommands">
<MkFollowButton v-if="userDetailed" :class="$style.followCommandButton" :user="userDetailed" :transparent="false" :full="false"/>
</div>
</template>
<template v-else-if="notification.type === 'followRequestAccepted'">
<div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div>
@ -136,6 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="full && !followRequestDone" :class="$style.followRequestCommands">
<MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
<MkFollowButton v-if="userDetailed" :class="$style.followCommandButton" :user="userDetailed" :transparent="false" :full="false"/>
</div>
</template>
<span v-else-if="notification.type === 'test'" :class="$style.text">{{ i18n.ts._notification.notificationWillBeDisplayedLikeThis }}</span>
@ -179,8 +183,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { Ref, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { UserDetailed } from 'misskey-js/autogen/models.js';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
@ -190,6 +195,7 @@ import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { signinRequired } from '@/account.js';
import { infoImageUrl } from '@/instance.js';
import MkFollowButton from '@/components/MkFollowButton.vue';
const $i = signinRequired();
@ -202,6 +208,26 @@ const props = withDefaults(defineProps<{
full: false,
});
const userDetailed: Ref<UserDetailed | null> = ref(null);
// watch() is required because computed() doesn't support async.
watch(props, async () => {
const type = props.notification.type;
// To avoid extra lookups, only do the query when it actually matters.
if (type === 'follow' || type === 'receiveFollowRequest') {
const user = await misskeyApi('users/show', {
userId: props.notification.userId,
});
userDetailed.value = user;
followRequestDone.value = !user.hasPendingFollowRequestToYou;
} else {
userDetailed.value = null;
followRequestDone.value = false;
}
}, { immediate: true });
type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' };
const exportEntityName = {
@ -216,7 +242,7 @@ const exportEntityName = {
userList: i18n.ts.lists,
} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>;
const followRequestDone = ref(false);
const followRequestDone = ref(true);
const acceptFollowRequest = () => {
if (!('user' in props.notification)) return;
@ -434,13 +460,24 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
.followRequestCommands {
display: flex;
gap: 8px;
max-width: 300px;
margin-top: 8px;
width: 100%;
}
.followRequestCommandButton {
max-width: 175px;
width: 100%;
}
.flexSpacer {
flex: 1;
}
.followCommandButton {
margin-left: auto;
flex-grow: 0;
flex-shrink: 0;
}
.reactionsItem {
display: inline-block;
position: relative;

View file

@ -366,6 +366,29 @@ if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
cw.value = props.reply.cw;
}
// apply default CW
if ($i.defaultCW) {
useCw.value = true;
if (!cw.value || $i.defaultCWPriority === 'default') {
cw.value = $i.defaultCW;
} else if ($i.defaultCWPriority !== 'parent') {
// This is a fancy way of simulating /\bsearch\b/ without a regular expression.
// We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries.
const parts = cw.value.split($i.defaultCW);
const hasExistingDefaultCW = parts.length === 2 && !/\w$/.test(parts[0]) && !/^\w/.test(parts[1]);
if (!hasExistingDefaultCW) {
// We need to merge the CWs
if ($i.defaultCWPriority === 'defaultParent') {
cw.value = `${$i.defaultCW}, ${cw.value}`;
} else if ($i.defaultCWPriority === 'parentDefault') {
cw.value = `${cw.value}, ${$i.defaultCW}`;
}
}
}
// else { do nothing, because existing CW takes priority. }
}
function watchForDraft() {
watch(text, () => saveDraft());
watch(useCw, () => saveDraft());

View file

@ -358,6 +358,10 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
}
}
case 'group': { // this is mostly a hack for the insides of `ruby`
style = '';
break;
}
case 'unixtime': {
const child = token.children[0];
const unixtime = parseInt(child.type === 'text' ? child.props.text : '');

View file

@ -155,10 +155,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch>
</div>
</MkFolder>
<MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch>
<MkInput v-model="defaultCW" type="text" manualSave @update:modelValue="save()">
<template #label>{{ i18n.ts.defaultCW }}</template>
<template #caption>{{ i18n.ts.defaultCWDescription }}</template>
</MkInput>
<MkSelect v-model="defaultCWPriority" :disabled="!defaultCW || !keepCw" @update:modelValue="save()">
<template #label>{{ i18n.ts.defaultCWPriority }}</template>
<template #caption>{{ i18n.ts.defaultCWPriorityDescription }}</template>
<option value="default">{{ i18n.ts._defaultCWPriority.default }}</option>
<option value="parent">{{ i18n.ts._defaultCWPriority.parent }}</option>
<option value="parentDefault">{{ i18n.ts._defaultCWPriority.parentDefault }}</option>
<option value="defaultParent">{{ i18n.ts._defaultCWPriority.defaultParent }}</option>
</MkSelect>
</div>
</FormSection>
<MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch>
</div>
</template>
@ -194,6 +208,8 @@ const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions);
const followingVisibility = ref($i.followingVisibility);
const followersVisibility = ref($i.followersVisibility);
const defaultCW = ref($i.defaultCW);
const defaultCWPriority = ref($i.defaultCWPriority);
const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility'));
const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly'));
@ -252,6 +268,8 @@ function save() {
publicReactions: !!publicReactions.value,
followingVisibility: followingVisibility.value,
followersVisibility: followersVisibility.value,
defaultCWPriority: defaultCWPriority.value,
defaultCW: defaultCW.value,
});
}

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
<div class="banner-container" :style="style">
<div class="banner-container" :class="{ [$style.bannerContainerTall]: useTallBanner }" :style="style">
<div ref="bannerEl" class="banner" :style="style"></div>
<div class="fade"></div>
<div class="title">
@ -39,12 +39,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<li v-if="user.isBlocking">{{ i18n.ts.blocked }}</li>
<li v-if="user.isBlocked && $i.isModerator">{{ i18n.ts.blockingYou }}</li>
</ul>
<div class="actions">
<button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
<div :class="$style.actions" class="actions">
<button :class="$style.actionsMenu" class="menu _button" @click="menu"><i class="ti ti-dots"></i></button>
<MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :class="$style.actionsFollow" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" class="koudoku" @update:wait="onFollowButtonDisabledChanged"/>
<div v-if="hasFollowRequest" :class="$style.actionsBanner">{{ i18n.ts.receiveFollowRequest }}</div>
<MkButton v-if="hasFollowRequest" :class="$style.actionsAccept" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" rounded primary @click="acceptFollowRequest"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton v-if="hasFollowRequest" :class="$style.actionsReject" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" rounded danger @click="rejectFollowRequest"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
</div>
</div>
<MkAvatar class="avatar" :user="user" indicator/>
<MkAvatar class="avatar" :class="{ [$style.avatarTall]: useTallBanner }" :user="user" indicator/>
<div class="title">
<MkUserName :user="user" :nowrap="false" class="name"/>
<div class="bottom">
@ -220,8 +223,8 @@ import MkSparkle from '@/components/MkSparkle.vue';
const MkNote = defineAsyncComponent(() =>
defaultStore.state.noteDesign === 'sharkey'
? import('@/components/SkNote.vue')
: import('@/components/MkNote.vue'),
? import('@/components/SkNote.vue')
: import('@/components/MkNote.vue'),
);
function calcAge(birthdate: string): number {
@ -387,6 +390,42 @@ async function updateMemo() {
isEditingMemo.value = false;
}
// Set true to disable the follow / follow request controls
const disableFollowControls = ref(false);
const hasFollowRequest = computed(() => user.value.hasPendingFollowRequestToYou);
const useTallBanner = computed(() => hasFollowRequest.value && narrow.value);
async function onFollowButtonDisabledChanged(disabled: boolean) {
try {
// Refresh the UI after MkFollowButton changes the follow relation
if (!disabled) {
user.value = await os.apiWithDialog('users/show', { userId: user.value.id });
}
} finally {
disableFollowControls.value = disabled;
}
}
async function acceptFollowRequest() {
try {
disableFollowControls.value = true;
await os.apiWithDialog('following/requests/accept', { userId: user.value.id });
user.value = await os.apiWithDialog('users/show', { userId: user.value.id });
} finally {
disableFollowControls.value = false;
}
}
async function rejectFollowRequest() {
try {
disableFollowControls.value = true;
await os.apiWithDialog('following/requests/reject', { userId: user.value.id });
user.value = await os.apiWithDialog('users/show', { userId: user.value.id });
} finally {
disableFollowControls.value = false;
}
}
watch([props.user], () => {
memoDraft.value = props.user.memo;
});
@ -863,4 +902,48 @@ onUnmounted(() => {
margin-left: 8px;
}
}
.actions {
display: grid;
grid-template-rows: min-content min-content min-content;
grid-template-columns: min-content auto 1fr;
grid-template-areas:
"menu follow follow"
"banner banner banner"
"accept accept reject";
}
.actionsMenu {
grid-area: menu;
width: unset;
}
.actionsFollow {
grid-area: follow;
margin-left: 8px;
}
.actionsBanner {
grid-area: banner;
justify-self: center;
margin-top: 8px;
margin-bottom: 4px;
}
.actionsAccept {
grid-area: accept;
}
.actionsReject {
grid-area: reject;
margin-left: 8px;
}
.bannerContainerTall {
height: 200px !important;
}
.avatarTall {
top: 150px !important;
}
</style>

View file

@ -4244,6 +4244,9 @@ export type components = {
/** Format: date-time */
lastUsed: string;
}[];
defaultCW: string | null;
/** @enum {string} */
defaultCWPriority: 'default' | 'parent' | 'defaultParent' | 'parentDefault';
};
UserDetailedNotMe: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'];
MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly'];
@ -22777,6 +22780,9 @@ export type operations = {
};
emailNotificationTypes?: string[];
alsoKnownAs?: string[];
defaultCW?: string | null;
/** @enum {string} */
defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault';
};
};
};

View file

@ -438,4 +438,14 @@ _permissions:
robotsTxt: "Custom robots.txt"
robotsTxtDescription: "Adding entries here will override the default robots.txt packaged with Sharkey."
defaultCW: "Default content warning for new posts"
defaultCWDescription: "The value here will be auto-filled as the content warning for all new posts and replies."
defaultCWPriority: "Automatic CW priority"
defaultCWPriorityDescription: "Select preferred action when default CW and keep CW settings are both enabled at the same time."
_defaultCWPriority:
default: "Use Default (use the default CW, ignoring the inherited CW)"
parent: "Use Parent (use the inherited CW, ignoring the default CW)"
defaultParent: "Use Default, then Parent (use the default CW, and append the inherited CW)"
parentDefault: "Use Parent, then Default (use the inherited CW, and append the default CW)"
id: "ID"