638f5a8312
* Fix avatar path resolution in dev-env * changeset * extract dev-env assets to dedicated folder * add comment * fix fmt
663 lines
19 KiB
TypeScript
663 lines
19 KiB
TypeScript
import { AtpAgent, AtUri } from '@atproto/api'
|
|
import { TestNetwork, SeedClient, RecordRef, basicSeed } from '@atproto/dev-env'
|
|
import { forSnapshot } from '../_util'
|
|
import { ids } from '../../src/lexicon/lexicons'
|
|
|
|
describe('pds views with blocking from block lists', () => {
|
|
let network: TestNetwork
|
|
let agent: AtpAgent
|
|
let pdsAgent: AtpAgent
|
|
let sc: SeedClient
|
|
let aliceReplyToDan: { ref: RecordRef }
|
|
|
|
let alice: string
|
|
let bob: string
|
|
let carol: string
|
|
let dan: string
|
|
|
|
beforeAll(async () => {
|
|
network = await TestNetwork.create({
|
|
dbPostgresSchema: 'views_block_lists',
|
|
})
|
|
agent = network.bsky.getClient()
|
|
pdsAgent = network.pds.getClient()
|
|
sc = network.getSeedClient()
|
|
await basicSeed(sc)
|
|
alice = sc.dids.alice
|
|
bob = sc.dids.bob
|
|
carol = sc.dids.carol
|
|
dan = sc.dids.dan
|
|
// add follows to ensure blocks work even w follows
|
|
await sc.follow(carol, dan)
|
|
await sc.follow(dan, carol)
|
|
aliceReplyToDan = await sc.reply(
|
|
alice,
|
|
sc.posts[dan][0].ref,
|
|
sc.posts[dan][0].ref,
|
|
'alice replies to dan',
|
|
)
|
|
await network.processAll()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await network.close()
|
|
})
|
|
|
|
let listUri: string
|
|
|
|
it('creates a list with some items', async () => {
|
|
const avatar = await sc.uploadFile(
|
|
alice,
|
|
'../dev-env/assets/key-portrait-small.jpg',
|
|
'image/jpeg',
|
|
)
|
|
// alice creates block list with bob & carol that dan uses
|
|
const list = await pdsAgent.api.app.bsky.graph.list.create(
|
|
{ repo: alice },
|
|
{
|
|
name: 'alice blocks',
|
|
purpose: 'app.bsky.graph.defs#modlist',
|
|
description: 'big list of blocks',
|
|
avatar: avatar.image,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
listUri = list.uri
|
|
await pdsAgent.api.app.bsky.graph.listitem.create(
|
|
{ repo: alice },
|
|
{
|
|
subject: sc.dids.bob,
|
|
list: list.uri,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
await pdsAgent.api.app.bsky.graph.listitem.create(
|
|
{ repo: alice },
|
|
{
|
|
subject: sc.dids.carol,
|
|
list: list.uri,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
await pdsAgent.api.app.bsky.graph.listitem.create(
|
|
{ repo: alice },
|
|
{
|
|
subject: sc.dids.dan,
|
|
list: list.uri,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
await network.processAll()
|
|
})
|
|
|
|
it('uses a list for blocks', async () => {
|
|
await pdsAgent.api.app.bsky.graph.listblock.create(
|
|
{ repo: dan },
|
|
{
|
|
subject: listUri,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(dan),
|
|
)
|
|
await network.processAll()
|
|
})
|
|
|
|
it('blocks thread post', async () => {
|
|
const { carol, dan } = sc.dids
|
|
const { data: threadAlice } = await agent.api.app.bsky.feed.getPostThread(
|
|
{ depth: 1, uri: sc.posts[carol][0].ref.uriStr },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyFeedGetPostThread,
|
|
),
|
|
},
|
|
)
|
|
expect(threadAlice.thread).toEqual(
|
|
expect.objectContaining({
|
|
$type: 'app.bsky.feed.defs#blockedPost',
|
|
uri: sc.posts[carol][0].ref.uriStr,
|
|
blocked: true,
|
|
}),
|
|
)
|
|
const { data: threadCarol } = await agent.api.app.bsky.feed.getPostThread(
|
|
{ depth: 1, uri: sc.posts[dan][0].ref.uriStr },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetPostThread,
|
|
),
|
|
},
|
|
)
|
|
expect(threadCarol.thread).toEqual(
|
|
expect.objectContaining({
|
|
$type: 'app.bsky.feed.defs#blockedPost',
|
|
uri: sc.posts[dan][0].ref.uriStr,
|
|
blocked: true,
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('blocks thread reply', async () => {
|
|
// Contains reply by carol
|
|
const { data: thread } = await agent.api.app.bsky.feed.getPostThread(
|
|
{ depth: 1, uri: sc.posts[alice][1].ref.uriStr },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyFeedGetPostThread,
|
|
),
|
|
},
|
|
)
|
|
expect(forSnapshot(thread)).toMatchSnapshot()
|
|
})
|
|
|
|
it('blocks thread parent', async () => {
|
|
// Parent is a post by dan
|
|
const { data: thread } = await agent.api.app.bsky.feed.getPostThread(
|
|
{ depth: 1, uri: aliceReplyToDan.ref.uriStr },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetPostThread,
|
|
),
|
|
},
|
|
)
|
|
expect(forSnapshot(thread)).toMatchSnapshot()
|
|
})
|
|
|
|
it('blocks record embeds', async () => {
|
|
// Contains a deep embed of carol's post, blocked by dan
|
|
const { data: thread } = await agent.api.app.bsky.feed.getPostThread(
|
|
{ depth: 0, uri: sc.posts[alice][2].ref.uriStr },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyFeedGetPostThread,
|
|
),
|
|
},
|
|
)
|
|
expect(forSnapshot(thread)).toMatchSnapshot()
|
|
})
|
|
|
|
it('errors on getting author feed', async () => {
|
|
const attempt1 = agent.api.app.bsky.feed.getAuthorFeed(
|
|
{ actor: carol },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyFeedGetAuthorFeed,
|
|
),
|
|
},
|
|
)
|
|
await expect(attempt1).rejects.toMatchObject({
|
|
error: 'BlockedActor',
|
|
})
|
|
|
|
const attempt2 = agent.api.app.bsky.feed.getAuthorFeed(
|
|
{ actor: dan },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetAuthorFeed,
|
|
),
|
|
},
|
|
)
|
|
await expect(attempt2).rejects.toMatchObject({
|
|
error: 'BlockedByActor',
|
|
})
|
|
})
|
|
|
|
it('strips blocked users out of getTimeline', async () => {
|
|
const resCarol = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetTimeline,
|
|
),
|
|
},
|
|
)
|
|
expect(
|
|
resCarol.data.feed.some((post) => post.post.author.did === dan),
|
|
).toBeFalsy()
|
|
|
|
const resDan = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyFeedGetTimeline),
|
|
},
|
|
)
|
|
expect(
|
|
resDan.data.feed.some((post) =>
|
|
[bob, carol].includes(post.post.author.did),
|
|
),
|
|
).toBeFalsy()
|
|
})
|
|
|
|
it('returns block status on getProfile', async () => {
|
|
const resCarol = await agent.api.app.bsky.actor.getProfile(
|
|
{ actor: dan },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorGetProfile,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.viewer?.blocking).toBeUndefined()
|
|
expect(resCarol.data.viewer?.blockingByList).toBeUndefined()
|
|
expect(resCarol.data.viewer?.blockedBy).toBe(true)
|
|
|
|
const resDan = await agent.api.app.bsky.actor.getProfile(
|
|
{ actor: carol },
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyActorGetProfile),
|
|
},
|
|
)
|
|
expect(resDan.data.viewer?.blocking).toBeDefined()
|
|
expect(resDan.data.viewer?.blockingByList?.uri).toEqual(
|
|
resDan.data.viewer?.blocking,
|
|
)
|
|
expect(resDan.data.viewer?.blockedBy).toBe(false)
|
|
})
|
|
|
|
it('returns block status on getProfiles', async () => {
|
|
const resCarol = await agent.api.app.bsky.actor.getProfiles(
|
|
{ actors: [alice, dan] },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorGetProfiles,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.profiles[0].viewer?.blocking).toBeUndefined()
|
|
expect(resCarol.data.profiles[0].viewer?.blockingByList).toBeUndefined()
|
|
expect(resCarol.data.profiles[0].viewer?.blockedBy).toBe(false)
|
|
expect(resCarol.data.profiles[1].viewer?.blocking).toBeUndefined()
|
|
expect(resCarol.data.profiles[1].viewer?.blockingByList).toBeUndefined()
|
|
expect(resCarol.data.profiles[1].viewer?.blockedBy).toBe(true)
|
|
|
|
const resDan = await agent.api.app.bsky.actor.getProfiles(
|
|
{ actors: [alice, carol] },
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyActorGetProfiles),
|
|
},
|
|
)
|
|
expect(resDan.data.profiles[0].viewer?.blocking).toBeUndefined()
|
|
expect(resDan.data.profiles[0].viewer?.blockingByList).toBeUndefined()
|
|
expect(resDan.data.profiles[0].viewer?.blockedBy).toBe(false)
|
|
expect(resDan.data.profiles[1].viewer?.blocking).toBeDefined()
|
|
expect(resDan.data.profiles[1].viewer?.blockingByList?.uri).toEqual(
|
|
resDan.data.profiles[1].viewer?.blocking,
|
|
)
|
|
expect(resDan.data.profiles[1].viewer?.blockedBy).toBe(false)
|
|
})
|
|
|
|
it('ignores self-blocks', async () => {
|
|
const res = await agent.api.app.bsky.actor.getProfile(
|
|
{ actor: dan }, // dan subscribes to list that contains himself
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyActorGetProfile),
|
|
},
|
|
)
|
|
expect(res.data.viewer?.blocking).toBeUndefined()
|
|
expect(res.data.viewer?.blockingByList).toBeUndefined()
|
|
expect(res.data.viewer?.blockedBy).toBe(false)
|
|
})
|
|
|
|
it('does not return notifs for blocked accounts', async () => {
|
|
const resCarol = await agent.api.app.bsky.notification.listNotifications(
|
|
{
|
|
limit: 100,
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyNotificationListNotifications,
|
|
),
|
|
},
|
|
)
|
|
expect(
|
|
resCarol.data.notifications.some((notif) => notif.author.did === dan),
|
|
).toBeFalsy()
|
|
|
|
const resDan = await agent.api.app.bsky.notification.listNotifications(
|
|
{
|
|
limit: 100,
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyNotificationListNotifications,
|
|
),
|
|
},
|
|
)
|
|
expect(
|
|
resDan.data.notifications.some((notif) => notif.author.did === carol),
|
|
).toBeFalsy()
|
|
})
|
|
|
|
it('does not return blocked accounts in actor search', async () => {
|
|
const resCarol = await agent.api.app.bsky.actor.searchActors(
|
|
{
|
|
term: 'dan.test',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorSearchActors,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy()
|
|
|
|
const resDan = await agent.api.app.bsky.actor.searchActors(
|
|
{
|
|
term: 'carol.test',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyActorSearchActors,
|
|
),
|
|
},
|
|
)
|
|
expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy()
|
|
})
|
|
|
|
it('does not return blocked accounts in actor search typeahead', async () => {
|
|
const resCarol = await agent.api.app.bsky.actor.searchActorsTypeahead(
|
|
{
|
|
term: 'dan.tes',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorSearchActorsTypeahead,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy()
|
|
|
|
const resDan = await agent.api.app.bsky.actor.searchActorsTypeahead(
|
|
{
|
|
term: 'carol.tes',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyActorSearchActorsTypeahead,
|
|
),
|
|
},
|
|
)
|
|
expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy()
|
|
})
|
|
|
|
it('does return blocked accounts in actor search typeahead when term is exact handle', async () => {
|
|
const resCarol = await agent.api.app.bsky.actor.searchActorsTypeahead(
|
|
{
|
|
term: 'dan.test',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorSearchActorsTypeahead,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeTruthy()
|
|
|
|
const resDan = await agent.api.app.bsky.actor.searchActorsTypeahead(
|
|
{
|
|
term: 'carol.test',
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyActorSearchActorsTypeahead,
|
|
),
|
|
},
|
|
)
|
|
expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeTruthy()
|
|
})
|
|
|
|
it('does not return blocked accounts in get suggestions', async () => {
|
|
// unfollow so they _would_ show up in suggestions if not for block
|
|
await sc.unfollow(carol, dan)
|
|
await sc.unfollow(dan, carol)
|
|
await network.processAll()
|
|
|
|
const resCarol = await agent.api.app.bsky.actor.getSuggestions(
|
|
{
|
|
limit: 100,
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyActorGetSuggestions,
|
|
),
|
|
},
|
|
)
|
|
expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy()
|
|
|
|
const resDan = await agent.api.app.bsky.actor.getSuggestions(
|
|
{
|
|
limit: 100,
|
|
},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyActorGetSuggestions,
|
|
),
|
|
},
|
|
)
|
|
expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy()
|
|
})
|
|
|
|
it('returns the contents of a list', async () => {
|
|
const res = await agent.api.app.bsky.graph.getList(
|
|
{ list: listUri },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetList) },
|
|
)
|
|
expect(forSnapshot(res.data)).toMatchSnapshot()
|
|
})
|
|
|
|
it('paginates getList', async () => {
|
|
const full = await agent.api.app.bsky.graph.getList(
|
|
{ list: listUri },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetList) },
|
|
)
|
|
const first = await agent.api.app.bsky.graph.getList(
|
|
{ list: listUri, limit: 1 },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetList) },
|
|
)
|
|
const second = await agent.api.app.bsky.graph.getList(
|
|
{ list: listUri, cursor: first.data.cursor },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetList) },
|
|
)
|
|
const combined = [...first.data.items, ...second.data.items]
|
|
expect(combined).toEqual(full.data.items)
|
|
})
|
|
|
|
let otherListUri: string
|
|
|
|
it('returns lists associated with a user', async () => {
|
|
const listRes = await pdsAgent.api.app.bsky.graph.list.create(
|
|
{ repo: alice },
|
|
{
|
|
name: 'new list',
|
|
purpose: 'app.bsky.graph.defs#modlist',
|
|
description: 'blah blah',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
otherListUri = listRes.uri
|
|
await network.processAll()
|
|
|
|
const res = await agent.api.app.bsky.graph.getLists(
|
|
{ actor: alice },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetLists) },
|
|
)
|
|
expect(forSnapshot(res.data)).toMatchSnapshot()
|
|
})
|
|
|
|
it('paginates getLists', async () => {
|
|
const full = await agent.api.app.bsky.graph.getLists(
|
|
{ actor: alice },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetLists) },
|
|
)
|
|
const first = await agent.api.app.bsky.graph.getLists(
|
|
{ actor: alice, limit: 1 },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetLists) },
|
|
)
|
|
const second = await agent.api.app.bsky.graph.getLists(
|
|
{ actor: alice, cursor: first.data.cursor },
|
|
{ headers: await network.serviceHeaders(dan, ids.AppBskyGraphGetLists) },
|
|
)
|
|
const combined = [...first.data.lists, ...second.data.lists]
|
|
expect(combined).toEqual(full.data.lists)
|
|
})
|
|
|
|
it('returns a users own list blocks', async () => {
|
|
await pdsAgent.api.app.bsky.graph.listblock.create(
|
|
{ repo: dan },
|
|
{
|
|
subject: otherListUri,
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
sc.getHeaders(dan),
|
|
)
|
|
await network.processAll()
|
|
|
|
const res = await agent.api.app.bsky.graph.getListBlocks(
|
|
{},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyGraphGetListBlocks,
|
|
),
|
|
},
|
|
)
|
|
expect(forSnapshot(res.data)).toMatchSnapshot()
|
|
})
|
|
|
|
it('paginates getListBlocks', async () => {
|
|
const full = await agent.api.app.bsky.graph.getListBlocks(
|
|
{},
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyGraphGetListBlocks,
|
|
),
|
|
},
|
|
)
|
|
const first = await agent.api.app.bsky.graph.getListBlocks(
|
|
{ limit: 1 },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyGraphGetListBlocks,
|
|
),
|
|
},
|
|
)
|
|
const second = await agent.api.app.bsky.graph.getListBlocks(
|
|
{ cursor: first.data.cursor },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
dan,
|
|
ids.AppBskyGraphGetListBlocks,
|
|
),
|
|
},
|
|
)
|
|
const combined = [...first.data.lists, ...second.data.lists]
|
|
expect(combined).toEqual(full.data.lists)
|
|
})
|
|
|
|
it('does not apply "curate" blocklists', async () => {
|
|
const parsedUri = new AtUri(listUri)
|
|
await pdsAgent.api.com.atproto.repo.putRecord(
|
|
{
|
|
repo: parsedUri.hostname,
|
|
collection: parsedUri.collection,
|
|
rkey: parsedUri.rkey,
|
|
record: {
|
|
name: 'curate list',
|
|
purpose: 'app.bsky.graph.defs#curatelist',
|
|
createdAt: new Date().toISOString(),
|
|
},
|
|
},
|
|
{ headers: sc.getHeaders(alice), encoding: 'application/json' },
|
|
)
|
|
await network.processAll()
|
|
|
|
const resCarol = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetTimeline,
|
|
),
|
|
},
|
|
)
|
|
expect(
|
|
resCarol.data.feed.some((post) => post.post.author.did === dan),
|
|
).toBeTruthy()
|
|
|
|
const resDan = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyFeedGetTimeline),
|
|
},
|
|
)
|
|
expect(
|
|
resDan.data.feed.some((post) =>
|
|
[bob, carol].includes(post.post.author.did),
|
|
),
|
|
).toBeTruthy()
|
|
})
|
|
|
|
it('does not apply deleted blocklists (whose items are still around)', async () => {
|
|
const parsedUri = new AtUri(listUri)
|
|
await pdsAgent.api.app.bsky.graph.list.delete(
|
|
{
|
|
repo: parsedUri.hostname,
|
|
rkey: parsedUri.rkey,
|
|
},
|
|
sc.getHeaders(alice),
|
|
)
|
|
await network.processAll()
|
|
|
|
const resCarol = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(
|
|
carol,
|
|
ids.AppBskyFeedGetTimeline,
|
|
),
|
|
},
|
|
)
|
|
expect(
|
|
resCarol.data.feed.some((post) => post.post.author.did === dan),
|
|
).toBeTruthy()
|
|
|
|
const resDan = await agent.api.app.bsky.feed.getTimeline(
|
|
{ limit: 100 },
|
|
{
|
|
headers: await network.serviceHeaders(dan, ids.AppBskyFeedGetTimeline),
|
|
},
|
|
)
|
|
expect(
|
|
resDan.data.feed.some((post) =>
|
|
[bob, carol].includes(post.post.author.did),
|
|
),
|
|
).toBeTruthy()
|
|
})
|
|
})
|