This commit is contained in:
kenshineto 2026-06-24 19:51:46 -04:00 committed by GitHub
commit ceecda6823
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 227 additions and 85 deletions

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserAcct1772983353696 {
name = 'UserAcct1772983353696'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "acct" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "user"."acct" IS 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.'`);
await queryRunner.query(`CREATE INDEX "IDX_0be9d7dcbac33e23aba1637a69" ON "user" ("acct") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_0be9d7dcbac33e23aba1637a69"`);
await queryRunner.query(`COMMENT ON COLUMN "user"."acct" IS 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "acct"`);
}
}

View file

@ -148,15 +148,15 @@ export class AntennaService implements OnApplicationShutdown {
} else if (antenna.src === 'users') {
const accts = antenna.users.map(x => {
const { username, host } = Acct.parse(x);
return this.utilityService.getFullApAccount(username, host).toLowerCase();
return this.utilityService.getFullApAccount({ username, host }).toLowerCase();
});
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
if (!accts.includes(this.utilityService.getFullApAccount(noteUser).toLowerCase())) return false;
} else if (antenna.src === 'users_blacklist') {
const accts = antenna.users.map(x => {
const { username, host } = Acct.parse(x);
return this.utilityService.getFullApAccount(username, host).toLowerCase();
return this.utilityService.getFullApAccount({ username, host }).toLowerCase();
});
if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
if (accts.includes(this.utilityService.getFullApAccount(noteUser).toLowerCase())) return false;
}
const keywords = antenna.keywords
@ -225,11 +225,11 @@ export class AntennaService implements OnApplicationShutdown {
// There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it.
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
const srcUserAcct = this.utilityService.getFullApAccount(src).toLowerCase();
const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
return antenna.users.some(user => {
const { username, host } = Acct.parse(user);
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
return this.utilityService.getFullApAccount({ username, host }).toLowerCase() === srcUserAcct;
});
});

View file

@ -18,6 +18,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { bindThis } from '@/decorators.js';
import * as Acct from '@/misc/acct.js';
@Injectable()
export class RemoteUserResolveService {
@ -67,12 +68,15 @@ export class RemoteUserResolveService {
}) as MiLocalUser;
}
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
const acctLower = `${usernameLower}@${host}`;
const user = await this.usersRepository.findOneBy([
{ usernameLower, host },
{ acct: acctLower },
]) as MiRemoteUser | null;
if (user == null) {
const self = await this.resolveSelf(acctLower);
const { self, subject } = await this.resolveWebfinger(acctLower);
if (this.utilityService.isUriLocal(self.href)) {
const local = this.apDbResolverService.parseUri(self.href);
@ -90,8 +94,20 @@ export class RemoteUserResolveService {
}
}
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
return await this.apPersonService.createPerson(self.href);
this.logger.succ(`return new remote user: ${chalk.magenta(subject)}`);
const newUser = await this.apPersonService.createPerson({ uri: self.href, acct: subject });
if (newUser.acct !== subject) {
await this.usersRepository.update({
id: newUser.id,
}, {
acct: subject
});
newUser.acct = subject;
}
return newUser;
}
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
@ -102,7 +118,7 @@ export class RemoteUserResolveService {
});
this.logger.info(`try resync: ${acctLower}`);
const self = await this.resolveSelf(acctLower);
const { self, subject } = await this.resolveWebfinger(acctLower);
if (user.uri !== self.href) {
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
@ -120,12 +136,13 @@ export class RemoteUserResolveService {
host: host,
}, {
uri: self.href,
acct: subject,
});
} else {
this.logger.info(`uri is fine: ${acctLower}`);
}
await this.apPersonService.updatePerson(self.href);
await this.apPersonService.updatePerson({ uri: self.href, acct: subject });
this.logger.info(`return resynced remote user: ${acctLower}`);
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
@ -142,7 +159,7 @@ export class RemoteUserResolveService {
}
@bindThis
private async resolveSelf(acctLower: string): Promise<ILink> {
private async resolveWebfinger(acctLower: string): Promise<{ self: ILink, subject: string | null }> {
this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`);
@ -153,6 +170,11 @@ export class RemoteUserResolveService {
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
throw new Error('self link not found');
}
return self;
let subject = Acct.validate(finger.subject) ? finger.subject :
Acct.validate(acctLower) ? acctLower : null;
if (subject) {
subject = Acct.parse(subject).toString();
}
return { self, subject };
}
}

View file

@ -35,11 +35,13 @@ const logger = new Logger('following/create');
type Local = MiLocalUser | {
id: MiLocalUser['id'];
host: MiLocalUser['host'];
acct: MiLocalUser['acct'];
uri: MiLocalUser['uri']
};
type Remote = MiRemoteUser | {
id: MiRemoteUser['id'];
host: MiRemoteUser['host'];
acct: MiRemoteUser['acct'];
uri: MiRemoteUser['uri'];
inbox: MiRemoteUser['inbox'];
};

View file

@ -12,6 +12,7 @@ import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
import { MiInstance } from '@/models/Instance.js';
import { MiUser } from '@/models/User.js';
@Injectable()
export class UtilityService {
@ -25,8 +26,12 @@ export class UtilityService {
}
@bindThis
public getFullApAccount(username: string, host: string | null): string {
return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`;
public getFullApAccount(user: { username: string, host: string | null, acct?: string | null }): string {
if (user.acct) {
return user.acct;
}
return user.host ? `${user.username}@${this.toPuny(user.host)}` : `${user.username}@${this.toPuny(this.config.host)}`;
}
@bindThis

View file

@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
uri: null,
followersUri: null,
token: null,
acct: null,
...override,
};
}
@ -411,6 +412,7 @@ export class WebhookTestService {
name: user.name,
username: user.username,
host: user.host,
acct: user.acct,
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? '',
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({

View file

@ -301,10 +301,11 @@ export class ApPersonService implements OnModuleInit {
* Personを作成します
*/
@bindThis
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
public async createPerson(args: string | { uri: string, acct?: string | null }, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof args === 'string') args = { uri: args };
if (typeof args.uri !== 'string') throw new Error('uri is not string');
const host = this.utilityService.punyHost(uri);
const host = this.utilityService.punyHost(args.uri);
if (host === this.utilityService.toPuny(this.config.host)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
@ -312,10 +313,10 @@ export class ApPersonService implements OnModuleInit {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
const object = await resolver.resolve(args.uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
const person = this.validateActor(object, uri);
const person = this.validateActor(object, args.uri);
this.logger.info(`Creating the Person: ${person.id}`);
@ -381,6 +382,7 @@ export class ApPersonService implements OnModuleInit {
username: person.preferredUsername,
usernameLower: person.preferredUsername?.toLowerCase(),
host,
acct: args.acct ?? null,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined,
@ -488,23 +490,24 @@ export class ApPersonService implements OnModuleInit {
* @param movePreventUris URIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない
*/
@bindThis
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof uri !== 'string') throw new Error('uri is not string');
public async updatePerson(args: string | { uri: string, acct?: string | null }, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
if (typeof args === 'string') args = { uri: args };
if (typeof args.uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (this.utilityService.isUriLocal(uri)) return;
if (this.utilityService.isUriLocal(args.uri)) return;
//#region このサーバーに既に登録されているか
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
const exist = await this.fetchPerson(args.uri) as MiRemoteUser | null;
if (exist === null) return;
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
const object = hint ?? await resolver.resolve(args.uri);
const person = this.validateActor(object, uri);
const person = this.validateActor(object, args.uri);
this.logger.info(`Updating the Person: ${person.id}`);
@ -557,6 +560,7 @@ export class ApPersonService implements OnModuleInit {
const updates = {
lastFetchedAt: new Date(),
acct: args.acct,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
followersUri: person.followers ? getApId(person.followers) : undefined,
@ -639,7 +643,7 @@ export class ApPersonService implements OnModuleInit {
const updated = { ...exist, ...updates };
this.cacheService.uriPersonCache.set(uri, updated);
this.cacheService.uriPersonCache.set(args.uri, updated);
// 移行処理を行う
if (updated.movedAt && (
@ -649,14 +653,14 @@ export class ApPersonService implements OnModuleInit {
// Mastodonのクールダウン期間は30日だが若干緩めに設定しておく
exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime()
)) {
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${args.uri})`);
return this.processRemoteMove(updated, movePreventUris)
.then(result => {
this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`);
this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${args.uri})`);
return result;
})
.catch(e => {
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${args.uri})`, { stack: e });
});
}

View file

@ -387,7 +387,8 @@ export class UserEntityService implements OnModuleInit {
if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合
return this.meta.iconUrl;
} else {
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
const acct = user.acct?.toLowerCase() ?? `${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
return `${this.config.url}/identicon/${acct}`;
}
}
@ -488,6 +489,7 @@ export class UserEntityService implements OnModuleInit {
name: user.name,
username: user.username,
host: user.host,
acct: user.acct,
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({

View file

@ -9,6 +9,7 @@ export type Acct = {
};
export function parse(acct: string): Acct {
if (acct.startsWith('acct:')) acct = acct.substring(5);
if (acct.startsWith('@')) acct = acct.substring(1);
const split = acct.split('@', 2);
return { username: split[0], host: split[1] ?? null };
@ -17,3 +18,7 @@ export function parse(acct: string): Acct {
export function toString(acct: Acct): string {
return acct.host == null ? acct.username : `${acct.username}@${acct.host}`;
}
export function validate(acct: string): boolean {
return acct.match(/^(acct:)?[@]?[^@]+@[^@]+\.[^@]+$/) !== null;
}

View file

@ -247,6 +247,13 @@ export class MiUser {
})
public host: string | null;
@Index()
@Column('varchar', {
length: 512, nullable: true,
comment: 'The last retrieved webfinger subject of the User. It will be null if the origin of the user is local.',
})
public acct: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'The inbox URL of the User. It will be null if the origin of the user is local.',
@ -297,23 +304,27 @@ export class MiUser {
export type MiLocalUser = MiUser & {
host: null;
uri: null;
acct: null;
};
export type MiPartialLocalUser = Partial<MiUser> & {
id: MiUser['id'];
host: null;
uri: null;
acct: null;
};
export type MiRemoteUser = MiUser & {
host: string;
uri: string;
acct: string | null;
};
export type MiPartialRemoteUser = Partial<MiUser> & {
id: MiUser['id'];
host: string;
uri: string;
acct: string | null;
};
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;

View file

@ -191,6 +191,10 @@ export const packedUserLiteSchema = {
},
},
},
acct: {
type: 'string',
nullable: true, optional: false,
},
},
} as const;

View file

@ -80,7 +80,7 @@ export class ExportAntennasProcessorService {
excludeKeywords: antenna.excludeKeywords,
users: antenna.users,
userListAccts: typeof users !== 'undefined' ? users.map((u) => {
return this.utilityService.getFullApAccount(u.username, u.host); // acct
return this.utilityService.getFullApAccount(u); // acct
}) : null,
caseSensitive: antenna.caseSensitive,
localOnly: antenna.localOnly,

View file

@ -87,7 +87,7 @@ export class ExportBlockingProcessorService {
exportedCount++; continue;
}
const content = this.utilityService.getFullApAccount(u.username, u.host);
const content = this.utilityService.getFullApAccount(u);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {

View file

@ -94,7 +94,7 @@ export class ExportFollowingProcessorService {
continue;
}
const userAcct = this.utilityService.getFullApAccount(u.username, u.host);
const userAcct = this.utilityService.getFullApAccount(u);
const content = `${userAcct},withReplies=${following.withReplies}`;
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {

View file

@ -88,7 +88,7 @@ export class ExportMutingProcessorService {
exportedCount++; continue;
}
const content = this.utilityService.getFullApAccount(u.username, u.host);
const content = this.utilityService.getFullApAccount(u);
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {

View file

@ -70,7 +70,7 @@ export class ExportUserListsProcessorService {
const usersWithReplies = new Set(memberships.filter(m => m.withReplies).map(m => m.userId));
for (const u of users) {
const acct = this.utilityService.getFullApAccount(u.username, u.host);
const acct = this.utilityService.getFullApAccount(u);
// 3rd column and later will be key=value pairs
const content = `${list.name},${acct},withReplies=${usersWithReplies.has(u.id)}`;
await new Promise<void>((res, rej) => {

View file

@ -565,9 +565,11 @@ export class ClientServerService {
return;
}
const acct = user.acct ?? `@${user.username}${ user.host == null ? '' : '@' + user.host}`;
vary(reply.raw, 'Accept');
reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`);
reply.redirect(`/${acct}`);
});
// Note

View file

@ -14,7 +14,8 @@ export function NotePage(props: CommonProps<{
note: Packed<'Note'>;
profile: MiUserProfile;
}>) {
const title = props.note.user.name ? `${props.note.user.name} (@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''})` : `@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''}`
const acct = props.note.user.acct ?? `@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''}`;
const title = props.note.user.name ? `${props.note.user.name} (${acct})` : acct;
const isRenote = isRenotePacked(props.note);
const images = (props.note.files ?? []).filter(f => f.type.startsWith('image/'));
const videos = (props.note.files ?? []).filter(f => f.type.startsWith('video/'));

View file

@ -13,7 +13,8 @@ export function UserPage(props: CommonProps<{
profile: MiUserProfile;
sub?: string;
}>) {
const title = props.user.name ? `${props.user.name} (@${props.user.username}${props.user.host ? `@${props.user.host}` : ''})` : `@${props.user.username}${props.user.host ? `@${props.user.host}` : ''}`;
const acct = props.user.acct ?? `@${props.user.username}${props.user.host ? `@${props.user.host}` : ''}`;
const title = props.user.name ? `${props.user.name} (${acct})` : acct;
const me = props.profile.fields
? props.profile.fields
.filter(field => field.value != null && field.value.match(/^https?:/))

View file

@ -36,6 +36,7 @@ describe('ユーザー', () => {
name: user.name,
username: user.username,
host: user.host,
acct: user.acct,
avatarUrl: user.avatarUrl,
avatarBlurhash: user.avatarBlurhash,
avatarDecorations: user.avatarDecorations,
@ -309,6 +310,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.name, null);
assert.strictEqual(response.username, 'zoe');
assert.strictEqual(response.host, null);
assert.strictEqual(response.acct, null);
response.avatarUrl && assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/);
assert.strictEqual(response.avatarBlurhash, null);
assert.deepStrictEqual(response.avatarDecorations, []);

View file

@ -127,8 +127,8 @@ describe('ChannelFollowingService', () => {
});
beforeEach(async () => {
alice = { ...await createUser({ username: 'alice' }), host: null, uri: null };
bob = { ...await createUser({ username: 'bob' }), host: null, uri: null };
alice = { ...await createUser({ username: 'alice' }), host: null, uri: null, acct: null };
bob = { ...await createUser({ username: 'bob' }), host: null, uri: null, acct: null };
driveFile1 = await createDriveFile();
driveFile2 = await createDriveFile();
channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id });

View file

@ -5,20 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<span>
<span>@{{ user.username }}</span>
<span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
<span>@{{ acct.username }}</span>
<span v-if="acct.host || detail" style="opacity: 0.5;">@{{ acct.host || host }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import { toUnicode } from 'punycode.js';
import { host as hostRaw } from '@@/js/config.js';
defineProps<{
const props = defineProps<{
user: Misskey.entities.UserLite;
detail?: boolean;
}>();
const host = toUnicode(hostRaw);
const acct = computed(() => {
return Misskey.acct.fromUser(props.user);
});
</script>

View file

@ -250,6 +250,7 @@ export function userLite(id = 'someuserid', username = 'miskist', host: entities
id,
username,
host,
acct: host ? `@${username}@${host}` : null,
name,
onlineStatus: 'unknown',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',

View file

@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="user" :class="$style.root">
<div v-if="acct" :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
<MkMention :class="$style.link" :username="acct.username" :host="acct.host ?? localHost"/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n.js';
@ -21,6 +21,14 @@ import { misskeyApi } from '@/utility/misskey-api.js';
const user = ref<Misskey.entities.UserLite>();
const acct = computed(() => {
if (!user.value) {
return null;
}
return Misskey.acct.fromUser(user.value);
});
const props = defineProps<{
movedTo: string; // user id
}>();

View file

@ -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, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import * as os from '@/os.js';
@ -66,6 +66,10 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false);
const connection = useStream().useChannel('main');
const acct = computed(() => {
return Misskey.acct.fromUser(props.user);
});
if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', {
userId: props.user.id,
@ -84,7 +88,7 @@ async function onClick() {
const isLoggedIn = await pleaseLogin({
openOnRemote: {
type: 'web',
path: `/@${props.user.username}@${props.user.host ?? host}`,
path: `/@${acct.value.username}@${acct.value.host ?? host}`,
},
});
if (!isLoggedIn) return;

View file

@ -8,6 +8,7 @@ import { expect, userEvent, waitFor, within } from '@storybook/test';
import type { StoryObj } from '@storybook/vue3';
import { galleryPost } from '../../.storybook/fakes.js';
import MkGalleryPostPreview from './MkGalleryPostPreview.vue';
import * as Misskey from 'misskey-js';
export const Default = {
render(args) {
return {
@ -30,11 +31,12 @@ export const Default = {
};
},
async play({ canvasElement }) {
const acct = Misskey.acct.fromUser(galleryPost().user);
const canvas = within(canvasElement);
const links = canvas.getAllByRole('link');
expect(links).toHaveLength(2);
expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`);
expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`);
expect(links[1]).toHaveAttribute('href', `/@${Misskey.acct.toString(acct)}`);
const images = canvas.getAllByRole<HTMLImageElement>('img');
await waitFor(() => expect(Promise.all(images.map((image) => image.decode()))).resolves.toBeDefined());
},

View file

@ -355,28 +355,32 @@ if (props.mention) {
text.value += ' ';
}
if (replyTargetNote.value && (replyTargetNote.value.user.username !== $i.username || (replyTargetNote.value.user.host != null && replyTargetNote.value.user.host !== host))) {
text.value = `@${replyTargetNote.value.user.username}${replyTargetNote.value.user.host != null ? '@' + toASCII(replyTargetNote.value.user.host) : ''} `;
}
if (replyTargetNote.value) {
const replyUserAcct = Misskey.acct.fromUser(replyTargetNote.value.user);
if (replyTargetNote.value && replyTargetNote.value.text != null) {
const ast = mfm.parse(replyTargetNote.value.text);
const otherHost = replyTargetNote.value.user.host;
if (replyTargetNote.value.user.username !== $i.username || (replyUserAcct.host != null && replyUserAcct.host !== host)) {
text.value = `@${replyUserAcct.username}${replyUserAcct.host != null ? '@' + toASCII(replyUserAcct.host) : ''} `;
}
for (const x of extractMentions(ast)) {
const mention = x.host ?
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
if (replyTargetNote.value.text != null) {
const ast = mfm.parse(replyTargetNote.value.text);
const otherHost = replyUserAcct.host;
//
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
for (const x of extractMentions(ast)) {
const mention = x.host ?
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
//
if (text.value.includes(`${mention} `)) continue;
//
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
text.value += `${mention} `;
//
if (text.value.includes(`${mention} `)) continue;
text.value += `${mention} `;
}
}
}

View file

@ -48,6 +48,7 @@ const exampleNote = reactive<Misskey.entities.Note>({
name: '藍',
username: 'ai',
host: null,
acct: null,
avatarDecorations: [],
avatarUrl: '/client-assets/tutorial/ai.webp',
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',

View file

@ -52,6 +52,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({
name: '藍',
username: 'ai',
host: null,
acct: null,
avatarDecorations: [],
avatarUrl: '/client-assets/tutorial/ai.webp',
avatarBlurhash: 'eiKmhHIByXxZ~qWXs:-pR*NbR*s:xuRjoL-oR*WCt6WWf6WVf6oeWB',

View file

@ -5,20 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<span>
<span>@{{ user.username }}</span>
<span v-if="user.host || detail" style="opacity: 0.5;">@{{ user.host || host }}</span>
<span>@{{ acct.username }}</span>
<span v-if="acct.host || detail" style="opacity: 0.5;">@{{ acct.host || host }}</span>
</span>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import { toUnicode } from 'punycode.js';
import { host as hostRaw } from '@@/js/config.js';
defineProps<{
const props = defineProps<{
user: Misskey.entities.UserLite;
detail?: boolean;
}>();
const host = toUnicode(hostRaw);
const acct = computed(() => {
return Misskey.acct.fromUser(props.user);
});
</script>

View file

@ -69,9 +69,10 @@ function _fetch_() {
uri = uri.slice(5);
}
promise = misskeyApi('users/show', Misskey.acct.parse(uri)).then(user => {
const acct = Misskey.acct.fromUser(user);
mainRouter.replace('/@:acct/:page?', {
params: {
acct: user.host != null ? `${user.username}@${user.host}` : user.username,
acct: Misskey.acct.toString(acct),
},
});
});

View file

@ -8,8 +8,10 @@ import { HttpResponse, http } from 'msw';
import search_ from './search.vue';
import { userDetailed } from '@/../.storybook/fakes.js';
import { commonHandlers } from '@/../.storybook/mocks.js';
import * as Misskey from 'misskey-js';
const localUser = userDetailed('someuserid', 'miskist', null, 'Local Misskey User');
const localAcct = Misskey.acct.fromUser(localUser);
export const Default = {
render(args) {
@ -61,8 +63,8 @@ export const WithUsernameLocal = {
args: {
...Default.args,
username: localUser.username,
host: localUser.host,
username: localAcct.username,
host: localAcct.host,
},
parameters: {
layout: 'fullscreen',

View file

@ -23,6 +23,7 @@ import { getPluginHandlers } from '@/plugin.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) {
const meId = $i ? $i.id : null;
const acct = Misskey.acct.fromUser(user);
const cleanups = [] as (() => void)[];
@ -171,7 +172,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host ?? host}`);
copyToClipboard(`@${acct.username}@${acct.host ?? host}`);
},
});
@ -179,7 +180,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
icon: 'ti ti-share',
text: i18n.ts.copyProfileUrl,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
const canonical = `@${Misskey.acct.toString(acct)}`;
copyToClipboard(`${url}/${canonical}`);
},
});
@ -231,11 +232,11 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
text: i18n.ts.searchThisUsersNotes,
action: () => {
const query = {
username: user.username,
username: acct.username,
} as { username: string, host?: string };
if (user.host !== null) {
query.host = user.host;
if (acct.host !== null) {
query.host = acct.host;
}
router.push('/search', {
@ -289,7 +290,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
text: i18n.ts.addToAntenna,
children: async () => {
const antennas = await antennasCache.fetch();
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
const canonical = `@${Misskey.acct.toString(acct)}`;
return antennas.filter((a) => a.src === 'users').map(antenna => ({
text: antenna.name,
action: async () => {
@ -384,7 +385,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
icon: 'ti ti-pencil-heart',
text: i18n.ts.createUserSpecifiedNote,
action: () => {
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
const canonical = `@${Misskey.acct.toString(acct)}`;
os.post({ specified: user, initialText: `${canonical} ` });
},
});

View file

@ -10,11 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #sub>
<span>{{ countdownDate }}</span>
<span> / </span>
<span class="_monospace">@{{ acct(item.user) }}</span>
<span class="_monospace">@{{ acctFilter(item.user) }}</span>
</template>
</MkUserCardMini>
</MkA>
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
<button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${Misskey.acct.toString(acct)}`})">
<i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
</button>
</div>
@ -27,7 +27,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useLowresTime } from '@/composables/use-lowres-time.js';
import { userPage, acct } from '@/filters/user.js';
import { userPage, acct as acctFilter } from '@/filters/user.js';
const props = defineProps<{
item: Misskey.entities.UsersGetFollowingUsersByBirthdayResponse[number];
@ -54,6 +54,10 @@ const countdownDate = computed(() => {
return i18n.tsx._ago.daysAgo({ n: Math.abs(days) });
}
});
const acct = computed(() => {
return Misskey.acct.fromUser(props.item.user);
});
</script>
<style lang="scss" module>

View file

@ -27,6 +27,8 @@ declare namespace acct {
export {
parse,
toString_2 as toString,
fromUser,
validate,
Acct
}
}
@ -2457,6 +2459,9 @@ type FollowingUpdateResponse = operations['following___update']['responses']['20
// @public (undocumented)
export const followingVisibilities: readonly ["public", "followers", "private"];
// @public (undocumented)
function fromUser(u: UserLite): Acct;
// @public (undocumented)
type GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json'];
@ -3910,6 +3915,9 @@ type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestB
// @public (undocumented)
type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
function validate(acct: string): boolean;
// @public (undocumented)
type VerifyEmailRequest = operations['verify-email']['requestBody']['content']['application/json'];

View file

@ -1,3 +1,5 @@
import { UserLite } from './entities.js';
export type Acct = {
username: string;
host: string | null;
@ -5,6 +7,7 @@ export type Acct = {
export function parse(_acct: string): Acct {
let acct = _acct;
if (acct.startsWith('acct:')) acct = acct.substring(5);
if (acct.startsWith('@')) acct = acct.substring(1);
const split = acct.split('@', 2);
return { username: split[0], host: split[1] || null };
@ -13,3 +16,11 @@ export function parse(_acct: string): Acct {
export function toString(acct: Acct): string {
return acct.host == null ? acct.username : `${acct.username}@${acct.host}`;
}
export function fromUser(u: UserLite): Acct {
return u.acct ? parse(u.acct) : { username: u.username, host: u.host };
}
export function validate(acct: string): boolean {
return acct.match(/^(acct:)?[@]?[^@]+@[^@]+\.[^@]+$/) !== null;
}

View file

@ -4056,6 +4056,7 @@ export type components = {
iconUrl: string | null;
displayOrder: number;
}[];
acct: string | null;
};
UserDetailedNotMeOnly: {
/** Format: url */