diff options
| author | Julia <julia@insertdomain.name> | 2025-06-19 21:35:18 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-06-19 21:35:18 +0000 |
| commit | a77c32b17da63d3932b219f74152cce023a30f4a (patch) | |
| tree | d2a05796e942c8f250bbd01369eab0cbe5a14531 /packages/backend/src/server/api/stream | |
| parent | merge: release 2025.4.2 (!1051) (diff) | |
| parent | Merge branch 'develop' into release/2025.4.3 (diff) | |
| download | sharkey-a77c32b17da63d3932b219f74152cce023a30f4a.tar.gz sharkey-a77c32b17da63d3932b219f74152cce023a30f4a.tar.bz2 sharkey-a77c32b17da63d3932b219f74152cce023a30f4a.zip | |
merge: prepare release 2025.4.3 (!1125)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1125
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src/server/api/stream')
10 files changed, 154 insertions, 80 deletions
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index e0535a2f14..0ee7078eb2 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -36,7 +36,7 @@ export default class Connection { private channels = new Map<string, Channel>(); private subscribingNotes = new Map<string, number>(); public userProfile: MiUserProfile | null = null; - public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; + public following: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> = new Map(); public followingChannels: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = new Set(); diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 9af816dfbb..40ad454adb 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -61,12 +61,30 @@ export default abstract class Channel { return this.connection.subscriber; } + /** + * Checks if a note is visible to the current user *excluding* blocks and mutes. + */ + protected isNoteVisibleToMe(note: Packed<'Note'>): boolean { + if (note.visibility === 'public') return true; + if (note.visibility === 'home') return true; + if (!this.user) return false; + if (this.user.id === note.userId) return true; + if (note.visibility === 'followers') { + return this.following.has(note.userId); + } + if (!note.visibleUserIds) return false; + return note.visibleUserIds.includes(this.user.id); + } + /* * ミュートとブロックされてるを処理する */ protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // Ignore notes that require sign-in + if (note.user.requireSigninToViewContents && !this.user) return true; + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる - if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; + if (isInstanceMuted(note, this.userMutedInstances) && !this.following.has(note.userId)) return true; // 流れてきたNoteがミュートしているユーザーが関わる if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; @@ -79,6 +97,15 @@ export default abstract class Channel { // If it's a boost (pure renote) then we need to check the target as well if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true; + // Hide silenced notes + if (note.user.isSilenced || note.user.instance?.isSilenced) { + if (this.user == null) return true; + if (this.user.id === note.userId) return false; + if (!this.following.has(note.userId)) return true; + } + + // TODO muted threads + return false; } diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index d29101cbc5..72f719b411 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -5,13 +5,12 @@ import { Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import type { MiMeta } from '@/models/Meta.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { UtilityService } from '@/core/UtilityService.js'; import Channel, { MiChannelService } from '../channel.js'; class BubbleTimelineChannel extends Channel { @@ -21,11 +20,10 @@ class BubbleTimelineChannel extends Channel { private withRenotes: boolean; private withFiles: boolean; private withBots: boolean; - private instance: MiMeta; constructor( - private metaService: MetaService, private roleService: RoleService, + private readonly utilityService: UtilityService, noteEntityService: NoteEntityService, id: string, @@ -42,7 +40,6 @@ class BubbleTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); this.withBots = !!(params.withBots ?? true); - this.instance = await this.metaService.fetch(); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -50,20 +47,36 @@ class BubbleTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user?.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.host == null) return; - if (!this.instance.bubbleInstances.includes(note.user.host)) return; - if (note.user.requireSigninToViewContents && this.user == null) return; + if (!this.utilityService.isBubbledHost(note.user.host)) return; - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } - if (this.isNoteMutedOrBlocked(note)) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); @@ -85,17 +98,17 @@ export class BubbleTimelineChannelService implements MiChannelService<false> { public readonly kind = BubbleTimelineChannel.kind; constructor( - private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, + private readonly utilityService: UtilityService, ) { } @bindThis public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { return new BubbleTimelineChannel( - this.metaService, this.roleService, + this.utilityService, this.noteEntityService, id, connection, diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index c899ad9490..5c73f637c7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user?.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.requireSigninToViewContents && this.user == null) return; - if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; - if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } - if (this.isNoteMutedOrBlocked(note)) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index dfdb491113..c7062c0394 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -47,40 +47,32 @@ class HomeTimelineChannel extends Channel { if (!this.followingChannels.has(note.channelId)) return; } else { // その投稿のユーザーをフォローしていなかったら弾く - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; + if (!isMe && !this.following.has(note.userId)) return; } - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; - } + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if (this.following[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } - if (this.isNoteMutedOrBlocked(note)) return; - const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 6cb425ff81..7cb64c9f89 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -62,39 +62,31 @@ class HybridTimelineChannel extends Channel { // フォローしているチャンネルの投稿 の場合だけ if (!( (note.channelId == null && isMe) || - (note.channelId == null && Object.hasOwn(this.following, note.userId)) || + (note.channelId == null && this.following.has(note.userId)) || (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; - } - if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies && !this.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 82b128eae0..4869d871d6 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user?.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; if (note.user.host !== null) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.requireSigninToViewContents && this.user == null) return; - if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; - if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; + + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; // 関係ない返信は除外 - if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { + if (note.reply) { const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (this.isNoteMutedOrBlocked(note)) return; + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 6194bb78dd..193907504a 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -32,10 +32,12 @@ class MainChannel extends Channel { switch (data.type) { case 'notification': { // Ignore notifications from instances the user has muted - if (isUserFromMutedInstance(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; + if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return; if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { + if (this.isNoteMutedOrBlocked(data.body.note)) return; + if (!this.isNoteVisibleToMe(data.body.id)) return; const note = await this.noteEntityService.pack(data.body.note.id, this.user, { detail: true, }); @@ -44,9 +46,7 @@ class MainChannel extends Channel { break; } case 'mention': { - if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; - - if (this.userIdsWhoMeMuting.has(data.body.userId)) return; + if (this.isNoteMutedOrBlocked(data.body)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 78cd9bf868..a3886618f1 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class RoleTimelineChannel extends Channel { @@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { const note = data.body; + const isMe = this.user?.id === note.userId; + // TODO this should be cached if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { return; } @@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } + const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8a7c2b2633..4dae24a696 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = true as const; + public static kind = 'read:account'; private listId: string; private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private listUsersClock: NodeJS.Timeout; @@ -81,7 +82,7 @@ class UserListChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; + const isMe = this.user?.id === note.userId; // チャンネル投稿は無視する if (note.channelId) return; @@ -90,26 +91,28 @@ class UserListChannel extends Channel { if (!Object.hasOwn(this.membershipsMap, note.userId)) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!note.visibleUserIds!.includes(this.user!.id)) return; - } + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if (this.membershipsMap[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following.get(note.userId)?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (this.isNoteMutedOrBlocked(note)) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); @@ -128,7 +131,7 @@ class UserListChannel extends Channel { } @Injectable() -export class UserListChannelService implements MiChannelService<false> { +export class UserListChannelService implements MiChannelService<true> { public readonly shouldShare = UserListChannel.shouldShare; public readonly requireCredential = UserListChannel.requireCredential; public readonly kind = UserListChannel.kind; |