spec(skeb/role): Skeb募集中のクリエイターに自動でロールが付与されるように・バッジから募集状態の確認ができるように (MisskeyIO#593)

This commit is contained in:
まっちゃとーにゅ 2024-04-01 20:12:15 +09:00 committed by GitHub
parent 31ebd77e8a
commit 95838a036e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 323 additions and 44 deletions

View File

@ -1677,6 +1677,8 @@ _role:
iconUrl: "Icon URL" iconUrl: "Icon URL"
asBadge: "Show as badge" asBadge: "Show as badge"
descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on."
badgeBehavior: "Badge behavior"
descriptionOfBadgeBehavior: "Set the behavior of the badge icon."
isExplorable: "Make role explorable" isExplorable: "Make role explorable"
descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled." descriptionOfIsExplorable: "This role's timeline and the list of users with this will be made public if enabled."
displayOrder: "Position" displayOrder: "Position"

8
locales/index.d.ts vendored
View File

@ -6558,6 +6558,14 @@ export interface Locale extends ILocale {
* *
*/ */
"descriptionOfAsBadge": string; "descriptionOfAsBadge": string;
/**
*
*/
"badgeBehavior": string;
/**
*
*/
"descriptionOfBadgeBehavior": string;
/** /**
* *
*/ */

View File

@ -1695,6 +1695,8 @@ _role:
iconUrl: "アイコン画像のURL" iconUrl: "アイコン画像のURL"
asBadge: "バッジとして表示" asBadge: "バッジとして表示"
descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。"
badgeBehavior: "バッジの挙動"
descriptionOfBadgeBehavior: "バッジの挙動を設定します。"
isExplorable: "ユーザーを見つけやすくする" isExplorable: "ユーザーを見つけやすくする"
descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。" descriptionOfIsExplorable: "オンにすると、「みつける」でメンバー一覧が公開されるほか、ロールのタイムラインが利用可能になります。"
displayOrder: "表示順" displayOrder: "表示順"

View File

@ -1661,6 +1661,8 @@ _role:
iconUrl: "아이콘 URL" iconUrl: "아이콘 URL"
asBadge: "뱃지로 표시" asBadge: "뱃지로 표시"
descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다." descriptionOfAsBadge: "활성화하면 유저명 옆에 역할의 아이콘이 표시됩니다."
badgeBehavior: "뱃지 동작"
descriptionOfBadgeBehavior: "뱃지의 동작 방식을 설정합니다."
isExplorable: "역할 타임라인 공개" isExplorable: "역할 타임라인 공개"
descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다." descriptionOfIsExplorable: "활성화하면 역할 타임라인을 공개합니다. 비활성화 시 타임라인이 공개되지 않습니다."
displayOrder: "표시 순서" displayOrder: "표시 순서"

View File

@ -0,0 +1,11 @@
export class RoleBadgeBehavior1711946753142 {
name = 'RoleBadgeBehavior1711946753142'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" ADD "badgeBehavior" character varying(256)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "badgeBehavior"`);
}
}

View File

@ -72,6 +72,7 @@ type Source = {
headers: { [x: string]: string }; headers: { [x: string]: string };
parameters: { [x: string]: string }; parameters: { [x: string]: string };
userIdParameterName: string; userIdParameterName: string;
roleId: string;
} }
proxy?: string; proxy?: string;
@ -154,6 +155,7 @@ export type Config = {
headers: { [x: string]: string }; headers: { [x: string]: string };
parameters: { [x: string]: string }; parameters: { [x: string]: string };
userIdParameterName: string; userIdParameterName: string;
roleId: string;
} | undefined; } | undefined;
proxy: string | undefined; proxy: string | undefined;
proxySmtp: string | undefined; proxySmtp: string | undefined;

View File

@ -577,6 +577,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
isModerator: values.isModerator, isModerator: values.isModerator,
isExplorable: values.isExplorable, isExplorable: values.isExplorable,
asBadge: values.asBadge, asBadge: values.asBadge,
badgeBehavior: values.badgeBehavior,
canEditMembersByModerator: values.canEditMembersByModerator, canEditMembersByModerator: values.canEditMembersByModerator,
displayOrder: values.displayOrder, displayOrder: values.displayOrder,
policies: values.policies, policies: values.policies,

View File

@ -68,6 +68,7 @@ export class RoleEntityService {
isModerator: role.isModerator, isModerator: role.isModerator,
isExplorable: role.isExplorable, isExplorable: role.isExplorable,
asBadge: role.asBadge, asBadge: role.asBadge,
badgeBehavior: role.badgeBehavior,
canEditMembersByModerator: role.canEditMembersByModerator, canEditMembersByModerator: role.canEditMembersByModerator,
displayOrder: role.displayOrder, displayOrder: role.displayOrder,
policies: policies, policies: policies,

View File

@ -484,6 +484,7 @@ export class UserEntityService implements OnModuleInit {
name: r.name, name: r.name,
iconUrl: r.iconUrl, iconUrl: r.iconUrl,
displayOrder: r.displayOrder, displayOrder: r.displayOrder,
behavior: r.badgeBehavior ?? undefined,
}))) : undefined, }))) : undefined,
...(isDetailed ? { ...(isDetailed ? {

View File

@ -155,6 +155,11 @@ export class MiRole {
}) })
public asBadge: boolean; public asBadge: boolean;
@Column('varchar', {
length: 256, nullable: true,
})
public badgeBehavior: string | null;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View File

@ -384,6 +384,10 @@ export const packedRoleSchema = {
optional: false, nullable: false, optional: false, nullable: false,
example: false, example: false,
}, },
badgeBehavior: {
type: 'string',
optional: false, nullable: true,
},
canEditMembersByModerator: { canEditMembersByModerator: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -176,6 +176,10 @@ export const packedUserLiteSchema = {
type: 'number', type: 'number',
nullable: false, optional: false, nullable: false, optional: false,
}, },
behavior: {
type: 'string',
nullable: false, optional: true,
}
}, },
}, },
}, },

View File

@ -36,6 +36,7 @@ export const paramDef = {
isAdministrator: { type: 'boolean' }, isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility
asBadge: { type: 'boolean' }, asBadge: { type: 'boolean' },
badgeBehavior: { type: 'string', nullable: true },
canEditMembersByModerator: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' }, displayOrder: { type: 'number' },
policies: { policies: {

View File

@ -42,6 +42,7 @@ export const paramDef = {
isAdministrator: { type: 'boolean' }, isAdministrator: { type: 'boolean' },
isExplorable: { type: 'boolean' }, isExplorable: { type: 'boolean' },
asBadge: { type: 'boolean' }, asBadge: { type: 'boolean' },
badgeBehavior: { type: 'string', nullable: true },
canEditMembersByModerator: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' },
displayOrder: { type: 'number' }, displayOrder: { type: 'number' },
policies: { policies: {
@ -92,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isAdministrator: ps.isAdministrator, isAdministrator: ps.isAdministrator,
isExplorable: ps.isExplorable, isExplorable: ps.isExplorable,
asBadge: ps.asBadge, asBadge: ps.asBadge,
badgeBehavior: ps.badgeBehavior,
canEditMembersByModerator: ps.canEditMembersByModerator, canEditMembersByModerator: ps.canEditMembersByModerator,
displayOrder: ps.displayOrder, displayOrder: ps.displayOrder,
policies: ps.policies, policies: ps.policies,

View File

@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { RoleService } from '@/core/RoleService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -88,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
private roleService: RoleService,
private loggerService: LoggerService, private loggerService: LoggerService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
) { ) {
@ -128,13 +130,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
error?: unknown, error?: unknown,
}; };
if (res.status > 399 || (json.error ?? json.ban_reason)) { const hasSkebRole = await this.roleService.getUserRoles(ps.userId).then(roles => roles.some(r => r.id === this.config.skebStatus?.roleId));
if (res.status > 299 || (json.error ?? json.ban_reason)) {
logger.error('Skeb status response error', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, error: json.error ?? json.ban_reason }); logger.error('Skeb status response error', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, error: json.error ?? json.ban_reason });
if (res.status === 404 && hasSkebRole) await this.roleService.unassign(ps.userId, this.config.skebStatus.roleId);
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
} }
logger.info('Skeb status response', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, skebStatus: json }); logger.info('Skeb status response', { url: url.href, userId: ps.userId, status: res.status, statusText: res.statusText, skebStatus: json });
if (json.is_acceptable) {
if (!hasSkebRole) await this.roleService.assign(ps.userId, this.config.skebStatus.roleId);
} else if (hasSkebRole) {
await this.roleService.unassign(ps.userId, this.config.skebStatus.roleId);
}
return { return {
screenName: json.screen_name, screenName: json.screen_name,
isCreator: json.is_creator, isCreator: json.is_creator,

View File

@ -467,6 +467,7 @@ describe('Note', () => {
isPublic: false, isPublic: false,
isExplorable: false, isExplorable: false,
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
policies: { policies: {
alwaysMarkNsfw: { alwaysMarkNsfw: {
@ -780,6 +781,7 @@ describe('Note', () => {
isPublic: false, isPublic: false,
isExplorable: false, isExplorable: false,
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
policies: { policies: {
mentionLimit: { mentionLimit: {
@ -834,6 +836,7 @@ describe('Note', () => {
isPublic: false, isPublic: false,
isExplorable: false, isExplorable: false,
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
policies: { policies: {
mentionLimit: { mentionLimit: {
@ -890,6 +893,7 @@ describe('Note', () => {
isPublic: false, isPublic: false,
isExplorable: false, isExplorable: false,
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
policies: { policies: {
mentionLimit: { mentionLimit: {

View File

@ -651,11 +651,20 @@ describe('ユーザー', () => {
}); });
test('を取得することができ、バッヂロールがセットされていること', async () => { test('を取得することができ、バッヂロールがセットされていること', async () => {
const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice });
if (roleBadge.badgeBehavior) {
assert.deepStrictEqual(response.badgeRoles, [{
name: roleBadge.name,
iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder,
behavior: roleBadge.badgeBehavior,
}]);
} else {
assert.deepStrictEqual(response.badgeRoles, [{ assert.deepStrictEqual(response.badgeRoles, [{
name: roleBadge.name, name: roleBadge.name,
iconUrl: roleBadge.iconUrl, iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder, displayOrder: roleBadge.displayOrder,
}]); }]);
}
assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない
}); });
test('をID指定のリスト形式で取得することができる', async () => { test('をID指定のリスト形式で取得することができる', async () => {

View File

@ -251,6 +251,7 @@ export const channel = async (user: UserToken, channel: Partial<misskey.entities
export const role = async (user: UserToken, role: Partial<misskey.entities.Role> = {}, policies: any = {}): Promise<misskey.entities.Role> => { export const role = async (user: UserToken, role: Partial<misskey.entities.Role> = {}, policies: any = {}): Promise<misskey.entities.Role> => {
const res = await api('admin/roles/create', { const res = await api('admin/roles/create', {
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
color: null, color: null,
condFormula: { condFormula: {

View File

@ -175,8 +175,8 @@ const align = () => {
let left; let left;
let top; let top;
const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset); const x = srcRect.left + (fixed.value ? 0 : window.scrollX);
const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset); const y = srcRect.top + (fixed.value ? 0 : window.scrollY);
if (props.anchor.x === 'center') { if (props.anchor.x === 'center') {
left = x + (props.src.offsetWidth / 2) - (width / 2); left = x + (props.src.offsetWidth / 2) - (width / 2);
@ -220,24 +220,24 @@ const align = () => {
} }
} else { } else {
// //
if (left + width - window.pageXOffset > (window.innerWidth - SCROLLBAR_THICKNESS)) { if (left + width - window.scrollX > (window.innerWidth - SCROLLBAR_THICKNESS)) {
left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset - 1; left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.scrollX - 1;
} }
const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.pageYOffset); const underSpace = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - (top - window.scrollY);
const upperSpace = (srcRect.top - MARGIN); const upperSpace = (srcRect.top - MARGIN);
// //
if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (top + height - window.scrollY > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') { if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) { if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace; maxHeight.value = underSpace;
} else { } else {
maxHeight.value = upperSpace; maxHeight.value = upperSpace;
top = window.pageYOffset + ((upperSpace + MARGIN) - height); top = window.scrollY + ((upperSpace + MARGIN) - height);
} }
} else { } else {
top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1; top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.scrollY - 1;
} }
} else { } else {
maxHeight.value = underSpace; maxHeight.value = underSpace;
@ -255,15 +255,15 @@ const align = () => {
let transformOriginX = 'center'; let transformOriginX = 'center';
let transformOriginY = 'center'; let transformOriginY = 'center';
if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) { if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'top'; transformOriginY = 'top';
} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) { } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.scrollY)) {
transformOriginY = 'bottom'; transformOriginY = 'bottom';
} }
if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) { if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'left'; transformOriginX = 'left';
} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) { } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.scrollX)) {
transformOriginX = 'right'; transformOriginX = 'right';
} }

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="note.user.isBot" :class="$style.isBot">bot</div> <div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div> <div :class="$style.username"><MkAcct :user="note.user"/></div>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles"> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/> <MkRoleBadgeIcon v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :userId="note.user.id" :role="role" :class="$style.badgeRole"/>
</div> </div>
<div :class="$style.info"> <div :class="$style.info">
<div v-if="mock"> <div v-if="mock">
@ -40,6 +40,7 @@ import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js'; import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js'; import { userPage } from '@/filters/user.js';
import MkRoleBadgeIcon from '@/components/MkRoleBadgeIcon.vue';
defineProps<{ defineProps<{
note: Misskey.entities.Note; note: Misskey.entities.Note;

View File

@ -0,0 +1,57 @@
<template>
<img ref="el" :src="role.iconUrl!" @click="onClick(role)"/>
</template>
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { instance } from '@/instance.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
const props = defineProps<{
userId: string,
role: { name: string, iconUrl: string | null, displayOrder: number, behavior?: string }
}>();
const el = ref<HTMLElement | { $el: HTMLElement }>();
const userSkebStatus = ref<Misskey.Endpoints['users/get-skeb-status']['res'] | null>(null);
async function fetchSkebStatus() {
if (!instance.enableSkebStatus || props.role.behavior !== 'skeb') {
userSkebStatus.value = null;
return;
}
userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.userId });
}
if (props.role.behavior === 'skeb') {
useTooltip(el, async (showing) => {
if (userSkebStatus.value == null) {
await fetchSkebStatus();
}
if (userSkebStatus.value === null) return;
os.popup(defineAsyncComponent(() => import('@/components/MkSkebStatusPopup.vue')), {
showing,
skebStatus: userSkebStatus.value,
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
}, {}, 'closed');
});
}
async function onClick(role: { name: string, iconUrl: string | null, displayOrder: number, behavior?: string }) {
if (role.behavior === 'skeb') {
if (userSkebStatus.value == null) {
await fetchSkebStatus();
}
if (userSkebStatus.value != null) {
window.open(`https://skeb.jp/@${userSkebStatus.value.screenName}`, '_blank', 'noopener');
}
}
}
</script>

View File

@ -0,0 +1,140 @@
<template>
<div ref="el" :class="$style.root" :style="{ zIndex }">
<Transition
:name="defaultStore.state.animation ? '_transition_zoom' : ''"
@afterLeave="emit('closed')"
>
<div v-if="showing" class="_popup _shadow">
<article :class="$style.body">
<header :class="$style.header">
<span v-if="skebStatus.isAcceptable" :class="$style.skebAcceptable">
{{ i18n.ts._skebStatus.seeking }}
</span>
<span v-else-if="skebStatus.isCreator" :class="$style.skebStopped">
{{ i18n.ts._skebStatus.stopped }}
</span>
<span v-else :class="$style.skebClient">
{{ i18n.ts._skebStatus.client }}
</span>
<Mfm v-if="skebStatus.creatorRequestCount > 0" :text="i18n.tsx._skebStatus.nWorks({ n: skebStatus.creatorRequestCount.toLocaleString() })" :nyaize="false" :colored="false"/>
<Mfm v-else-if="skebStatus.clientRequestCount > 0" :text="i18n.tsx._skebStatus.nRequests({ n: skebStatus.clientRequestCount.toLocaleString() })" :nyaize="false" :colored="false"/>
</header>
<div v-if="skebStatus.isAcceptable" :class="$style.divider"></div>
<div v-if="skebStatus.isAcceptable" class="contents _gaps_s">
<Mfm v-for="skill in skebStatus.skills" :key="skill.genre" :text="`${i18n.ts._skebStatus._genres[skill.genre]} ${i18n.tsx._skebStatus.yenX({ x: skill.amount.toLocaleString() })}`" :nyaize="false" :colored="false"/>
</div>
</article>
</div>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { calcPopupPosition } from '@/scripts/popup-position.js';
const props = defineProps<{
showing: boolean;
skebStatus: Misskey.Endpoints['users/get-skeb-status']['res'];
source: HTMLElement;
}>();
const emit = defineEmits<(ev: 'closed') => void>();
// showing = false closed DOM
if (!props.showing) emit('closed');
const el = shallowRef<HTMLElement>();
const zIndex = os.claimZIndex('high');
function setPosition() {
if (el.value == null) return;
const data = calcPopupPosition(el.value, {
anchorElement: props.source,
direction: 'bottom',
align: 'center',
innerMargin: 0,
});
el.value.style.transformOrigin = data.transformOrigin;
el.value.style.left = data.left + 'px';
el.value.style.top = data.top + 'px';
}
let loopHandler;
onMounted(() => {
nextTick(() => {
setPosition();
const loop = () => {
setPosition();
loopHandler = window.requestAnimationFrame(loop);
};
loop();
});
});
onUnmounted(() => {
window.cancelAnimationFrame(loopHandler);
});
</script>
<style lang="scss" module>
.root {
position: absolute;
padding: 8px 12px;
text-align: center;
pointer-events: none;
transform-origin: center center;
}
.body {
position: relative;
box-sizing: border-box;
padding: 16px;
}
.header {
display: flex;
gap: 2px;
align-items: center;
}
.divider {
margin: 8px auto;
border-top: solid 0.5px var(--divider);
}
.skebAcceptable,
.skebStopped,
.skebClient {
display: inline-flex;
border: solid 1px;
border-radius: 6px;
padding: 2px 6px;
margin-right: 4px;
font-size: 85%;
}
.skebAcceptable {
color: rgb(255, 255, 255);
background-color: rgb(241, 70, 104);
}
.skebStopped {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
.skebClient {
color: rgb(255, 255, 255);
background-color: rgb(54, 54, 54);
}
</style>

View File

@ -34,8 +34,8 @@ const left = ref(0);
onMounted(() => { onMounted(() => {
try { try {
const rect = props.source.getBoundingClientRect(); const rect = props.source.getBoundingClientRect();
const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.scrollX;
const y = rect.top + props.source.offsetHeight + window.pageYOffset; const y = rect.top + props.source.offsetHeight + window.scrollY;
top.value = y; top.value = y;
left.value = x; left.value = x;

View File

@ -63,6 +63,7 @@ if (props.id) {
isPublic: false, isPublic: false,
isExplorable: false, isExplorable: false,
asBadge: false, asBadge: false,
badgeBehavior: null,
canEditMembersByModerator: false, canEditMembersByModerator: false,
displayOrder: 0, displayOrder: 0,
policies: {}, policies: {},

View File

@ -67,6 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template> <template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template>
</MkSwitch> </MkSwitch>
<MkInput v-if="role.asBadge" v-model="role.badgeBehavior" :readonly="readonly">
<template #label>{{ i18n.ts._role.badgeBehavior }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfBadgeBehavior }}</template>
</MkInput>
<MkSwitch v-model="role.isExplorable" :readonly="readonly"> <MkSwitch v-model="role.isExplorable" :readonly="readonly">
<template #label>{{ i18n.ts._role.isExplorable }}</template> <template #label>{{ i18n.ts._role.isExplorable }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfIsExplorable }}</template> <template #caption>{{ i18n.ts._role.descriptionOfIsExplorable }}</template>
@ -848,6 +853,7 @@ const save = throttle(100, () => {
isPublic: role.value.isPublic, isPublic: role.value.isPublic,
isExplorable: role.value.isExplorable, isExplorable: role.value.isExplorable,
asBadge: role.value.asBadge, asBadge: role.value.asBadge,
badgeBehavior: role.value.badgeBehavior,
canEditMembersByModerator: role.value.canEditMembersByModerator, canEditMembersByModerator: role.value.canEditMembersByModerator,
policies: role.value.policies, policies: role.value.policies,
}; };

View File

@ -319,7 +319,6 @@ async function fetchSkebStatus() {
return; return;
} }
console.log('fetching skeb status');
userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id }); userSkebStatus.value = await misskeyApiGet('users/get-skeb-status', { userId: props.user.id });
} }

View File

@ -26,8 +26,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number; let top: number;
if (props.anchorElement) { if (props.anchorElement) {
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; top = (rect.top + window.scrollY - contentHeight) - props.innerMargin;
} else { } else {
left = props.x; left = props.x;
top = (props.y - contentHeight) - props.innerMargin; top = (props.y - contentHeight) - props.innerMargin;
@ -35,8 +35,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
left -= (el.offsetWidth / 2); left -= (el.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) { if (left + contentWidth - window.scrollX > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1; left = window.innerWidth - contentWidth + window.scrollX - 1;
} }
return [left, top]; return [left, top];
@ -47,8 +47,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number; let top: number;
if (props.anchorElement) { if (props.anchorElement) {
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin;
} else { } else {
left = props.x; left = props.x;
top = (props.y) + props.innerMargin; top = (props.y) + props.innerMargin;
@ -56,8 +56,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
left -= (el.offsetWidth / 2); left -= (el.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) { if (left + contentWidth - window.scrollX > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1; left = window.innerWidth - contentWidth + window.scrollX - 1;
} }
return [left, top]; return [left, top];
@ -68,8 +68,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number; let top: number;
if (props.anchorElement) { if (props.anchorElement) {
left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; left = (rect.left + window.scrollX - contentWidth) - props.innerMargin;
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
} else { } else {
left = (props.x - contentWidth) - props.innerMargin; left = (props.x - contentWidth) - props.innerMargin;
top = props.y; top = props.y;
@ -77,8 +77,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
top -= (el.offsetHeight / 2); top -= (el.offsetHeight / 2);
if (top + contentHeight - window.pageYOffset > window.innerHeight) { if (top + contentHeight - window.scrollY > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1; top = window.innerHeight - contentHeight + window.scrollY - 1;
} }
return [left, top]; return [left, top];
@ -89,15 +89,15 @@ export function calcPopupPosition(el: HTMLElement, props: {
let top: number; let top: number;
if (props.anchorElement) { if (props.anchorElement) {
left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin;
if (props.align === 'top') { if (props.align === 'top') {
top = rect.top + window.pageYOffset; top = rect.top + window.scrollY;
if (props.alignOffset != null) top += props.alignOffset; if (props.alignOffset != null) top += props.alignOffset;
} else if (props.align === 'bottom') { } else if (props.align === 'bottom') {
// TODO // TODO
} else { // center } else { // center
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2);
top -= (el.offsetHeight / 2); top -= (el.offsetHeight / 2);
} }
} else { } else {
@ -106,8 +106,8 @@ export function calcPopupPosition(el: HTMLElement, props: {
top -= (el.offsetHeight / 2); top -= (el.offsetHeight / 2);
} }
if (top + contentHeight - window.pageYOffset > window.innerHeight) { if (top + contentHeight - window.scrollY > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1; top = window.innerHeight - contentHeight + window.scrollY - 1;
} }
return [left, top]; return [left, top];
@ -123,7 +123,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
const [left, top] = calcPosWhenTop(); const [left, top] = calcPosWhenTop();
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す // ツールチップを上に向かって表示するスペースがなければ下に向かって出す
if (top - window.pageYOffset < 0) { if (top - window.scrollY < 0) {
const [left, top] = calcPosWhenBottom(); const [left, top] = calcPosWhenBottom();
return { left, top, transformOrigin: 'center top' }; return { left, top, transformOrigin: 'center top' };
} }
@ -141,7 +141,7 @@ export function calcPopupPosition(el: HTMLElement, props: {
const [left, top] = calcPosWhenLeft(); const [left, top] = calcPosWhenLeft();
// ツールチップを左に向かって表示するスペースがなければ右に向かって出す // ツールチップを左に向かって表示するスペースがなければ右に向かって出す
if (left - window.pageXOffset < 0) { if (left - window.scrollX < 0) {
const [left, top] = calcPosWhenRight(); const [left, top] = calcPosWhenRight();
return { left, top, transformOrigin: 'left center' }; return { left, top, transformOrigin: 'left center' };
} }

View File

@ -53,11 +53,11 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio
const rect = context.chart.canvas.getBoundingClientRect(); const rect = context.chart.canvas.getBoundingClientRect();
tooltipShowing.value = true; tooltipShowing.value = true;
tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX;
if (opts.position === 'top') { if (opts.position === 'top') {
tooltipY.value = rect.top + window.pageYOffset; tooltipY.value = rect.top + window.scrollY;
} else if (opts.position === 'middle') { } else if (opts.position === 'middle') {
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY;
} }
} }

View File

@ -3748,6 +3748,7 @@ export type components = {
name: string; name: string;
iconUrl: string | null; iconUrl: string | null;
displayOrder: number; displayOrder: number;
behavior?: string;
})[]; })[];
}; };
UserDetailedNotMeOnly: { UserDetailedNotMeOnly: {
@ -4843,6 +4844,7 @@ export type components = {
isExplorable: boolean; isExplorable: boolean;
/** @example false */ /** @example false */
asBadge: boolean; asBadge: boolean;
badgeBehavior: string | null;
/** @example false */ /** @example false */
canEditMembersByModerator: boolean; canEditMembersByModerator: boolean;
policies: { policies: {
@ -9827,6 +9829,7 @@ export type operations = {
/** @default false */ /** @default false */
isExplorable?: boolean; isExplorable?: boolean;
asBadge: boolean; asBadge: boolean;
badgeBehavior?: string | null;
canEditMembersByModerator: boolean; canEditMembersByModerator: boolean;
displayOrder: number; displayOrder: number;
policies: Record<string, never>; policies: Record<string, never>;
@ -10048,6 +10051,7 @@ export type operations = {
isAdministrator: boolean; isAdministrator: boolean;
isExplorable?: boolean; isExplorable?: boolean;
asBadge: boolean; asBadge: boolean;
badgeBehavior?: string | null;
canEditMembersByModerator: boolean; canEditMembersByModerator: boolean;
displayOrder: number; displayOrder: number;
policies: Record<string, never>; policies: Record<string, never>;