[APP-635] Mutelists (#601)
* Add lists and profilelist screens * Implement lists screen and lists-list in profiles * Add empty states to the lists screen * Switch (mostly) from blocklists to mutelists * Rework: create a new moderation screen and move everything related under it * Fix moderation screen on desktop web * Tune the empty state code * Change content moderation modal to content filtering * Add CreateMuteList modal * Implement mutelist creation * Add lists listings * Add the ability to create new mutelists * Add 'add to list' tool * Satisfy the hashtag hyphen haters * Add update/delete/subscribe/unsubscribe to lists * Show which list caused a mute * Add list un/subscribe * Add the mute override when viewing a profile's posts * Update to latest backend * Add simulation tests and tune some behaviors * Fix lint * Bump deps * Fix list refresh after creation * Mute list subscriptions -> Mute listszio/stable
parent
34d8fa5991
commit
ebcd633386
|
@ -91,6 +91,7 @@ async function main() {
|
||||||
'always-warn-profile',
|
'always-warn-profile',
|
||||||
'always-warn-posts',
|
'always-warn-posts',
|
||||||
'muted-account',
|
'muted-account',
|
||||||
|
'muted-by-list-account',
|
||||||
]) {
|
]) {
|
||||||
await server.mocker.createUser(user)
|
await server.mocker.createUser(user)
|
||||||
await server.mocker.follow('alice', user)
|
await server.mocker.follow('alice', user)
|
||||||
|
@ -258,11 +259,32 @@ async function main() {
|
||||||
await server.mocker.createPost('muted-account', 'muted post')
|
await server.mocker.createPost('muted-account', 'muted post')
|
||||||
await server.mocker.createQuotePost(
|
await server.mocker.createQuotePost(
|
||||||
'muted-account',
|
'muted-account',
|
||||||
'account quote post',
|
'muted quote post',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
await server.mocker.createReply(
|
await server.mocker.createReply(
|
||||||
'muted-account',
|
'muted-account',
|
||||||
|
'muted reply',
|
||||||
|
anchorPost,
|
||||||
|
)
|
||||||
|
|
||||||
|
const list = await server.mocker.createMuteList(
|
||||||
|
'alice',
|
||||||
|
'Muted Users',
|
||||||
|
)
|
||||||
|
await server.mocker.addToMuteList(
|
||||||
|
'alice',
|
||||||
|
list,
|
||||||
|
server.mocker.users['muted-by-list-account'].did,
|
||||||
|
)
|
||||||
|
await server.mocker.createPost('muted-by-list-account', 'muted post')
|
||||||
|
await server.mocker.createQuotePost(
|
||||||
|
'muted-by-list-account',
|
||||||
|
'account quote post',
|
||||||
|
anchorPost,
|
||||||
|
)
|
||||||
|
await server.mocker.createReply(
|
||||||
|
'muted-by-list-account',
|
||||||
'account reply',
|
'account reply',
|
||||||
anchorPost,
|
anchorPost,
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,141 @@
|
||||||
|
/* eslint-env detox/detox */
|
||||||
|
|
||||||
|
import {openApp, login, createServer, sleep} from '../util'
|
||||||
|
|
||||||
|
describe('Profile screen', () => {
|
||||||
|
let service: string
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = await createServer('?users&follows&labels')
|
||||||
|
await openApp({
|
||||||
|
permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Login and view my mutelists', async () => {
|
||||||
|
await expect(element(by.id('signInButton'))).toBeVisible()
|
||||||
|
await login(service, 'alice', 'hunter2')
|
||||||
|
await element(by.id('viewHeaderDrawerBtn')).tap()
|
||||||
|
await expect(element(by.id('drawer'))).toBeVisible()
|
||||||
|
await element(by.id('menuItemButton-Moderation')).tap()
|
||||||
|
await element(by.id('mutelistsBtn')).tap()
|
||||||
|
await expect(element(by.id('list-Muted Users'))).toBeVisible()
|
||||||
|
await element(by.id('list-Muted Users')).tap()
|
||||||
|
await expect(
|
||||||
|
element(by.id('user-muted-by-list-account.test')),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Toggle subscription', async () => {
|
||||||
|
await element(by.id('unsubscribeListBtn')).tap()
|
||||||
|
await element(by.id('subscribeListBtn')).tap()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Edit display name and description via the edit mutelist modal', async () => {
|
||||||
|
await element(by.id('editListBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||||
|
await element(by.id('editNameInput')).clearText()
|
||||||
|
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||||
|
await element(by.id('editDescriptionInput')).clearText()
|
||||||
|
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||||
|
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||||
|
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||||
|
// have to wait for the toast to clear
|
||||||
|
await waitFor(element(by.id('editListBtn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Remove description via the edit mutelist modal', async () => {
|
||||||
|
await element(by.id('editListBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||||
|
await element(by.id('editDescriptionInput')).clearText()
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||||
|
await expect(element(by.id('listDescription'))).not.toBeVisible()
|
||||||
|
// have to wait for the toast to clear
|
||||||
|
await waitFor(element(by.id('editListBtn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Set avi via the edit mutelist modal', async () => {
|
||||||
|
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||||
|
await element(by.id('editListBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||||
|
await element(by.id('changeAvatarBtn')).tap()
|
||||||
|
await element(by.id('changeAvatarLibraryBtn')).tap()
|
||||||
|
await sleep(3e3)
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||||
|
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||||
|
// have to wait for the toast to clear
|
||||||
|
await waitFor(element(by.id('editListBtn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Remove avi via the edit mutelist modal', async () => {
|
||||||
|
await expect(element(by.id('userAvatarImage'))).toExist()
|
||||||
|
await element(by.id('editListBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||||
|
await element(by.id('changeAvatarBtn')).tap()
|
||||||
|
await element(by.id('changeAvatarRemoveBtn')).tap()
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||||
|
await expect(element(by.id('userAvatarFallback'))).toExist()
|
||||||
|
// have to wait for the toast to clear
|
||||||
|
await waitFor(element(by.id('editListBtn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Delete the mutelist', async () => {
|
||||||
|
await element(by.id('deleteListBtn')).tap()
|
||||||
|
await element(by.id('confirmBtn')).tap()
|
||||||
|
await expect(element(by.id('emptyMuteLists'))).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Create a new mutelist', async () => {
|
||||||
|
await element(by.id('emptyMuteLists-button')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
|
||||||
|
await element(by.id('editNameInput')).typeText('Bad Ppl')
|
||||||
|
await element(by.id('editDescriptionInput')).typeText('They bad')
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
|
||||||
|
await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
|
||||||
|
await expect(element(by.id('listDescription'))).toHaveText('They bad')
|
||||||
|
// have to wait for the toast to clear
|
||||||
|
await waitFor(element(by.id('editListBtn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shows the mutelist on my profile', async () => {
|
||||||
|
await element(by.id('bottomBarProfileBtn')).tap()
|
||||||
|
await element(by.id('selector-2')).tap()
|
||||||
|
await element(by.id('list-Bad Ppl')).tap()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Adds and removes users on mutelists', async () => {
|
||||||
|
await element(by.id('bottomBarSearchBtn')).tap()
|
||||||
|
await element(by.id('searchTextInput')).typeText('bob')
|
||||||
|
await element(by.id('searchAutoCompleteResult-bob.test')).tap()
|
||||||
|
await expect(element(by.id('profileView'))).toBeVisible()
|
||||||
|
|
||||||
|
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||||
|
await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap()
|
||||||
|
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||||
|
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||||
|
|
||||||
|
await element(by.id('profileHeaderDropdownBtn')).tap()
|
||||||
|
await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap()
|
||||||
|
await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
|
||||||
|
await element(by.id('toggleBtn-Bad Ppl')).tap()
|
||||||
|
await element(by.id('saveBtn')).tap()
|
||||||
|
await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
|
@ -106,10 +106,12 @@ func serve(cctx *cli.Context) error {
|
||||||
// generic routes
|
// generic routes
|
||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGeneric)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGeneric)
|
||||||
|
e.GET("/moderation", server.WebGeneric)
|
||||||
|
e.GET("/moderation/mute-lists", server.WebGeneric)
|
||||||
|
e.GET("/moderation/muted-accounts", server.WebGeneric)
|
||||||
|
e.GET("/moderation/blocked-accounts", server.WebGeneric)
|
||||||
e.GET("/settings", server.WebGeneric)
|
e.GET("/settings", server.WebGeneric)
|
||||||
e.GET("/settings/app-passwords", server.WebGeneric)
|
e.GET("/settings/app-passwords", server.WebGeneric)
|
||||||
e.GET("/settings/muted-accounts", server.WebGeneric)
|
|
||||||
e.GET("/settings/blocked-accounts", server.WebGeneric)
|
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
e.GET("/support", server.WebGeneric)
|
e.GET("/support", server.WebGeneric)
|
||||||
|
@ -122,6 +124,7 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/profile/:handle", server.WebProfile)
|
e.GET("/profile/:handle", server.WebProfile)
|
||||||
e.GET("/profile/:handle/follows", server.WebGeneric)
|
e.GET("/profile/:handle/follows", server.WebGeneric)
|
||||||
e.GET("/profile/:handle/followers", server.WebGeneric)
|
e.GET("/profile/:handle/followers", server.WebGeneric)
|
||||||
|
e.GET("/profile/:handle/lists/:rkey", server.WebGeneric)
|
||||||
|
|
||||||
// post endpoints; only first populates info
|
// post endpoints; only first populates info
|
||||||
e.GET("/profile/:handle/post/:rkey", server.WebPost)
|
e.GET("/profile/:handle/post/:rkey", server.WebPost)
|
||||||
|
|
|
@ -337,6 +337,32 @@ class Mocker {
|
||||||
])
|
])
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createMuteList(user: string, name: string): Promise<string> {
|
||||||
|
const res = await this.users[user]?.agent.app.bsky.graph.list.create(
|
||||||
|
{repo: this.users[user]?.did},
|
||||||
|
{
|
||||||
|
purpose: 'app.bsky.graph.defs#modlist',
|
||||||
|
name,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await this.users[user]?.agent.app.bsky.graph.muteActorList({
|
||||||
|
list: res.uri,
|
||||||
|
})
|
||||||
|
return res.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
async addToMuteList(owner: string, list: string, subject: string) {
|
||||||
|
await this.users[owner]?.agent.app.bsky.graph.listitem.create(
|
||||||
|
{repo: this.users[owner]?.did},
|
||||||
|
{
|
||||||
|
list,
|
||||||
|
subject,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkAvailablePort = (port: number) =>
|
const checkAvailablePort = (port: number) =>
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.2.11",
|
"@atproto/api": "0.3.1",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@expo/webpack-config": "^18.0.1",
|
"@expo/webpack-config": "^18.0.1",
|
||||||
|
@ -140,7 +140,7 @@
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/pds": "^0.1.5",
|
"@atproto/pds": "^0.1.6",
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
|
|
|
@ -33,11 +33,14 @@ import {useStores} from './state'
|
||||||
import {HomeScreen} from './view/screens/Home'
|
import {HomeScreen} from './view/screens/Home'
|
||||||
import {SearchScreen} from './view/screens/Search'
|
import {SearchScreen} from './view/screens/Search'
|
||||||
import {NotificationsScreen} from './view/screens/Notifications'
|
import {NotificationsScreen} from './view/screens/Notifications'
|
||||||
|
import {ModerationScreen} from './view/screens/Moderation'
|
||||||
|
import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
|
||||||
import {NotFoundScreen} from './view/screens/NotFound'
|
import {NotFoundScreen} from './view/screens/NotFound'
|
||||||
import {SettingsScreen} from './view/screens/Settings'
|
import {SettingsScreen} from './view/screens/Settings'
|
||||||
import {ProfileScreen} from './view/screens/Profile'
|
import {ProfileScreen} from './view/screens/Profile'
|
||||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||||
|
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||||
import {PostThreadScreen} from './view/screens/PostThread'
|
import {PostThreadScreen} from './view/screens/PostThread'
|
||||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||||
|
@ -49,8 +52,8 @@ import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||||
import {MutedAccounts} from 'view/screens/MutedAccounts'
|
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||||
import {BlockedAccounts} from 'view/screens/BlockedAccounts'
|
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||||
import {getRoutingInstrumentation} from 'lib/sentry'
|
import {getRoutingInstrumentation} from 'lib/sentry'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
@ -70,6 +73,19 @@ function commonScreens(Stack: typeof HomeTab) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen name="NotFound" component={NotFoundScreen} />
|
<Stack.Screen name="NotFound" component={NotFoundScreen} />
|
||||||
|
<Stack.Screen name="Moderation" component={ModerationScreen} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="ModerationMuteLists"
|
||||||
|
component={ModerationMuteListsScreen}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ModerationMutedAccounts"
|
||||||
|
component={ModerationMutedAccounts}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="ModerationBlockedAccounts"
|
||||||
|
component={ModerationBlockedAccounts}
|
||||||
|
/>
|
||||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Profile" component={ProfileScreen} />
|
<Stack.Screen name="Profile" component={ProfileScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
@ -77,6 +93,7 @@ function commonScreens(Stack: typeof HomeTab) {
|
||||||
component={ProfileFollowersScreen}
|
component={ProfileFollowersScreen}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
|
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
|
||||||
|
<Stack.Screen name="ProfileList" component={ProfileListScreen} />
|
||||||
<Stack.Screen name="PostThread" component={PostThreadScreen} />
|
<Stack.Screen name="PostThread" component={PostThreadScreen} />
|
||||||
<Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
|
<Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
|
||||||
<Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
|
<Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
|
||||||
|
@ -91,8 +108,6 @@ function commonScreens(Stack: typeof HomeTab) {
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
|
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
|
||||||
<Stack.Screen name="AppPasswords" component={AppPasswords} />
|
<Stack.Screen name="AppPasswords" component={AppPasswords} />
|
||||||
<Stack.Screen name="MutedAccounts" component={MutedAccounts} />
|
|
||||||
<Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyActorDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
|
@ -16,6 +17,7 @@ import {
|
||||||
Label,
|
Label,
|
||||||
LabelValGroup,
|
LabelValGroup,
|
||||||
ModerationBehaviorCode,
|
ModerationBehaviorCode,
|
||||||
|
ModerationBehavior,
|
||||||
PostModeration,
|
PostModeration,
|
||||||
ProfileModeration,
|
ProfileModeration,
|
||||||
PostLabelInfo,
|
PostLabelInfo,
|
||||||
|
@ -127,11 +129,15 @@ export function getPostModeration(
|
||||||
|
|
||||||
// muting
|
// muting
|
||||||
if (postInfo.isMuted) {
|
if (postInfo.isMuted) {
|
||||||
|
let msg = 'Post from an account you muted.'
|
||||||
|
if (postInfo.mutedByList) {
|
||||||
|
msg = `Muted by ${postInfo.mutedByList.name}`
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
avatar,
|
avatar,
|
||||||
list: hide('Post from an account you muted.'),
|
list: isMute(hide(msg)),
|
||||||
thread: warn('Post from an account you muted.'),
|
thread: isMute(warn(msg)),
|
||||||
view: warn('Post from an account you muted.'),
|
view: isMute(warn(msg)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -273,6 +279,7 @@ export function getProfileViewBasicLabelInfo(
|
||||||
profileLabels: filterProfileLabels(profile.labels),
|
profileLabels: filterProfileLabels(profile.labels),
|
||||||
isMuted: profile.viewer?.muted || false,
|
isMuted: profile.viewer?.muted || false,
|
||||||
isBlocking: !!profile.viewer?.blocking || false,
|
isBlocking: !!profile.viewer?.blocking || false,
|
||||||
|
isBlockedBy: !!profile.viewer?.blockedBy || false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,6 +309,21 @@ export function getEmbedMuted(embed?: Embed): boolean {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEmbedMutedByList(
|
||||||
|
embed?: Embed,
|
||||||
|
): AppBskyGraphDefs.ListViewBasic | undefined {
|
||||||
|
if (!embed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
AppBskyEmbedRecord.isView(embed) &&
|
||||||
|
AppBskyEmbedRecord.isViewRecord(embed.record)
|
||||||
|
) {
|
||||||
|
return embed.record.author.viewer?.mutedByList
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function getEmbedBlocking(embed?: Embed): boolean {
|
export function getEmbedBlocking(embed?: Embed): boolean {
|
||||||
if (!embed) {
|
if (!embed) {
|
||||||
return false
|
return false
|
||||||
|
@ -401,6 +423,11 @@ function warnContent(reason: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMute(behavior: ModerationBehavior): ModerationBehavior {
|
||||||
|
behavior.isMute = true
|
||||||
|
return behavior
|
||||||
|
}
|
||||||
|
|
||||||
function warnImages(reason: string) {
|
function warnImages(reason: string) {
|
||||||
return {
|
return {
|
||||||
behavior: ModerationBehaviorCode.WarnImages,
|
behavior: ModerationBehaviorCode.WarnImages,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||||
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
import {LabelPreferencesModel} from 'state/models/ui/preferences'
|
||||||
|
|
||||||
export type Label = ComAtprotoLabelDefs.Label
|
export type Label = ComAtprotoLabelDefs.Label
|
||||||
|
@ -22,6 +22,7 @@ export interface PostLabelInfo {
|
||||||
accountLabels: Label[]
|
accountLabels: Label[]
|
||||||
profileLabels: Label[]
|
profileLabels: Label[]
|
||||||
isMuted: boolean
|
isMuted: boolean
|
||||||
|
mutedByList?: AppBskyGraphDefs.ListViewBasic
|
||||||
isBlocking: boolean
|
isBlocking: boolean
|
||||||
isBlockedBy: boolean
|
isBlockedBy: boolean
|
||||||
}
|
}
|
||||||
|
@ -44,6 +45,7 @@ export enum ModerationBehaviorCode {
|
||||||
|
|
||||||
export interface ModerationBehavior {
|
export interface ModerationBehavior {
|
||||||
behavior: ModerationBehaviorCode
|
behavior: ModerationBehaviorCode
|
||||||
|
isMute?: boolean
|
||||||
noOverride?: boolean
|
noOverride?: boolean
|
||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,15 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||||
|
|
||||||
export type CommonNavigatorParams = {
|
export type CommonNavigatorParams = {
|
||||||
NotFound: undefined
|
NotFound: undefined
|
||||||
|
Moderation: undefined
|
||||||
|
ModerationMuteLists: undefined
|
||||||
|
ModerationMutedAccounts: undefined
|
||||||
|
ModerationBlockedAccounts: undefined
|
||||||
Settings: undefined
|
Settings: undefined
|
||||||
Profile: {name: string; hideBackButton?: boolean}
|
Profile: {name: string; hideBackButton?: boolean}
|
||||||
ProfileFollowers: {name: string}
|
ProfileFollowers: {name: string}
|
||||||
ProfileFollows: {name: string}
|
ProfileFollows: {name: string}
|
||||||
|
ProfileList: {name: string; rkey: string}
|
||||||
PostThread: {name: string; rkey: string}
|
PostThread: {name: string; rkey: string}
|
||||||
PostLikedBy: {name: string; rkey: string}
|
PostLikedBy: {name: string; rkey: string}
|
||||||
PostRepostedBy: {name: string; rkey: string}
|
PostRepostedBy: {name: string; rkey: string}
|
||||||
|
@ -20,8 +25,6 @@ export type CommonNavigatorParams = {
|
||||||
CommunityGuidelines: undefined
|
CommunityGuidelines: undefined
|
||||||
CopyrightPolicy: undefined
|
CopyrightPolicy: undefined
|
||||||
AppPasswords: undefined
|
AppPasswords: undefined
|
||||||
MutedAccounts: undefined
|
|
||||||
BlockedAccounts: undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|
|
@ -94,6 +94,15 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listUriToHref(url: string): string {
|
||||||
|
try {
|
||||||
|
const {hostname, rkey} = new AtUri(url)
|
||||||
|
return `/profile/${hostname}/lists/${rkey}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getYoutubeVideoId(link: string): string | undefined {
|
export function getYoutubeVideoId(link: string): string | undefined {
|
||||||
let url
|
let url
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -5,17 +5,20 @@ export const router = new Router({
|
||||||
Search: '/search',
|
Search: '/search',
|
||||||
Notifications: '/notifications',
|
Notifications: '/notifications',
|
||||||
Settings: '/settings',
|
Settings: '/settings',
|
||||||
|
Moderation: '/moderation',
|
||||||
|
ModerationMuteLists: '/moderation/mute-lists',
|
||||||
|
ModerationMutedAccounts: '/moderation/muted-accounts',
|
||||||
|
ModerationBlockedAccounts: '/moderation/blocked-accounts',
|
||||||
Profile: '/profile/:name',
|
Profile: '/profile/:name',
|
||||||
ProfileFollowers: '/profile/:name/followers',
|
ProfileFollowers: '/profile/:name/followers',
|
||||||
ProfileFollows: '/profile/:name/follows',
|
ProfileFollows: '/profile/:name/follows',
|
||||||
|
ProfileList: '/profile/:name/lists/:rkey',
|
||||||
PostThread: '/profile/:name/post/:rkey',
|
PostThread: '/profile/:name/post/:rkey',
|
||||||
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
|
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
|
||||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||||
Debug: '/sys/debug',
|
Debug: '/sys/debug',
|
||||||
Log: '/sys/log',
|
Log: '/sys/log',
|
||||||
AppPasswords: '/settings/app-passwords',
|
AppPasswords: '/settings/app-passwords',
|
||||||
MutedAccounts: '/settings/muted-accounts',
|
|
||||||
BlockedAccounts: '/settings/blocked-accounts',
|
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
TermsOfService: '/support/tos',
|
TermsOfService: '/support/tos',
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
import {makeAutoObservable} from 'mobx'
|
||||||
|
import {AtUri, AppBskyGraphListitem} from '@atproto/api'
|
||||||
|
import {runInAction} from 'mobx'
|
||||||
|
import {RootStoreModel} from '../root-store'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100
|
||||||
|
interface Membership {
|
||||||
|
uri: string
|
||||||
|
value: AppBskyGraphListitem.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListMembershipModel {
|
||||||
|
// data
|
||||||
|
memberships: Membership[] = []
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel, public subject: string) {
|
||||||
|
makeAutoObservable(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
rootStore: false,
|
||||||
|
},
|
||||||
|
{autoBind: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// public api
|
||||||
|
// =
|
||||||
|
|
||||||
|
async fetch() {
|
||||||
|
// NOTE
|
||||||
|
// this approach to determining list membership is too inefficient to work at any scale
|
||||||
|
// it needs to be replaced with server side list membership queries
|
||||||
|
// -prf
|
||||||
|
let cursor
|
||||||
|
let records = []
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
cursor,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
records = records.concat(
|
||||||
|
res.records.filter(record => record.value.subject === this.subject),
|
||||||
|
)
|
||||||
|
cursor = res.cursor
|
||||||
|
if (!cursor) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInAction(() => {
|
||||||
|
this.memberships = records
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getMembership(listUri: string) {
|
||||||
|
return this.memberships.find(m => m.value.list === listUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
isMember(listUri: string) {
|
||||||
|
return !!this.getMembership(listUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(listUri: string) {
|
||||||
|
if (this.isMember(listUri)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
|
||||||
|
{
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: this.subject,
|
||||||
|
list: listUri,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const {rkey} = new AtUri(res.uri)
|
||||||
|
const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
rkey,
|
||||||
|
})
|
||||||
|
runInAction(() => {
|
||||||
|
this.memberships = this.memberships.concat([record])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(listUri: string) {
|
||||||
|
const membership = this.getMembership(listUri)
|
||||||
|
if (!membership) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {rkey} = new AtUri(membership.uri)
|
||||||
|
await this.rootStore.agent.app.bsky.graph.listitem.delete({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
rkey,
|
||||||
|
})
|
||||||
|
runInAction(() => {
|
||||||
|
this.memberships = this.memberships.filter(m => m.value.list !== listUri)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTo(uris: string) {
|
||||||
|
for (const uri of uris) {
|
||||||
|
await this.add(uri)
|
||||||
|
}
|
||||||
|
for (const membership of this.memberships) {
|
||||||
|
if (!uris.includes(membership.value.list)) {
|
||||||
|
await this.remove(membership.value.list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,257 @@
|
||||||
|
import {makeAutoObservable} from 'mobx'
|
||||||
|
import {
|
||||||
|
AtUri,
|
||||||
|
AppBskyGraphGetList as GetList,
|
||||||
|
AppBskyGraphDefs as GraphDefs,
|
||||||
|
AppBskyGraphList,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {RootStoreModel} from '../root-store'
|
||||||
|
import * as apilib from 'lib/api/index'
|
||||||
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
|
export class ListModel {
|
||||||
|
// state
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
hasLoaded = false
|
||||||
|
error = ''
|
||||||
|
loadMoreError = ''
|
||||||
|
hasMore = true
|
||||||
|
loadMoreCursor?: string
|
||||||
|
|
||||||
|
// data
|
||||||
|
list: GraphDefs.ListView | null = null
|
||||||
|
items: GraphDefs.ListItemView[] = []
|
||||||
|
|
||||||
|
static async createModList(
|
||||||
|
rootStore: RootStoreModel,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
avatar,
|
||||||
|
}: {name: string; description: string; avatar: RNImage | undefined},
|
||||||
|
) {
|
||||||
|
const record: AppBskyGraphList.Record = {
|
||||||
|
purpose: 'app.bsky.graph.defs#modlist',
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
avatar: undefined,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
if (avatar) {
|
||||||
|
const blobRes = await apilib.uploadBlob(
|
||||||
|
rootStore,
|
||||||
|
avatar.path,
|
||||||
|
avatar.mime,
|
||||||
|
)
|
||||||
|
record.avatar = blobRes.data.blob
|
||||||
|
}
|
||||||
|
const res = await rootStore.agent.app.bsky.graph.list.create(
|
||||||
|
{
|
||||||
|
repo: rootStore.me.did,
|
||||||
|
},
|
||||||
|
record,
|
||||||
|
)
|
||||||
|
await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel, public uri: string) {
|
||||||
|
makeAutoObservable(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
rootStore: false,
|
||||||
|
},
|
||||||
|
{autoBind: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasContent() {
|
||||||
|
return this.items.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasError() {
|
||||||
|
return this.error !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return this.hasLoaded && !this.hasContent
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOwner() {
|
||||||
|
return this.list?.creator.did === this.rootStore.me.did
|
||||||
|
}
|
||||||
|
|
||||||
|
// public api
|
||||||
|
// =
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
return this.loadMore(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||||
|
if (!replace && !this.hasMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._xLoading(replace)
|
||||||
|
try {
|
||||||
|
const res = await this.rootStore.agent.app.bsky.graph.getList({
|
||||||
|
list: this.uri,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
cursor: replace ? undefined : this.loadMoreCursor,
|
||||||
|
})
|
||||||
|
if (replace) {
|
||||||
|
this._replaceAll(res)
|
||||||
|
} else {
|
||||||
|
this._appendAll(res)
|
||||||
|
}
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async updateMetadata({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
avatar,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
avatar: RNImage | null | undefined
|
||||||
|
}) {
|
||||||
|
if (!this.isOwner) {
|
||||||
|
throw new Error('Cannot edit this list')
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the current record
|
||||||
|
const {rkey} = new AtUri(this.uri)
|
||||||
|
const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
rkey,
|
||||||
|
})
|
||||||
|
|
||||||
|
// update the fields
|
||||||
|
record.name = name
|
||||||
|
record.description = description
|
||||||
|
if (avatar) {
|
||||||
|
const blobRes = await apilib.uploadBlob(
|
||||||
|
this.rootStore,
|
||||||
|
avatar.path,
|
||||||
|
avatar.mime,
|
||||||
|
)
|
||||||
|
record.avatar = blobRes.data.blob
|
||||||
|
} else if (avatar === null) {
|
||||||
|
record.avatar = undefined
|
||||||
|
}
|
||||||
|
return await this.rootStore.agent.com.atproto.repo.putRecord({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
collection: 'app.bsky.graph.list',
|
||||||
|
rkey,
|
||||||
|
record,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
// fetch all the listitem records that belong to this list
|
||||||
|
let cursor
|
||||||
|
let records = []
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
cursor,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
records = records.concat(
|
||||||
|
res.records.filter(record => record.value.list === this.uri),
|
||||||
|
)
|
||||||
|
cursor = res.cursor
|
||||||
|
if (!cursor) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// batch delete the list and listitem records
|
||||||
|
const createDel = (uri: string) => {
|
||||||
|
const urip = new AtUri(uri)
|
||||||
|
return {
|
||||||
|
$type: 'com.atproto.repo.applyWrites#delete',
|
||||||
|
collection: urip.collection,
|
||||||
|
rkey: urip.rkey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.rootStore.agent.com.atproto.repo.applyWrites({
|
||||||
|
repo: this.rootStore.me.did,
|
||||||
|
writes: [createDel(this.uri)].concat(
|
||||||
|
records.map(record => createDel(record.uri)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe() {
|
||||||
|
await this.rootStore.agent.app.bsky.graph.muteActorList({
|
||||||
|
list: this.list.uri,
|
||||||
|
})
|
||||||
|
await this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe() {
|
||||||
|
await this.rootStore.agent.app.bsky.graph.unmuteActorList({
|
||||||
|
list: this.list.uri,
|
||||||
|
})
|
||||||
|
await this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to load more again after a failure
|
||||||
|
*/
|
||||||
|
async retryLoadMore() {
|
||||||
|
this.loadMoreError = ''
|
||||||
|
this.hasMore = true
|
||||||
|
return this.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// state transitions
|
||||||
|
// =
|
||||||
|
|
||||||
|
_xLoading(isRefreshing = false) {
|
||||||
|
this.isLoading = true
|
||||||
|
this.isRefreshing = isRefreshing
|
||||||
|
this.error = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
_xIdle(err?: any, loadMoreErr?: any) {
|
||||||
|
this.isLoading = false
|
||||||
|
this.isRefreshing = false
|
||||||
|
this.hasLoaded = true
|
||||||
|
this.error = cleanError(err)
|
||||||
|
this.loadMoreError = cleanError(loadMoreErr)
|
||||||
|
if (err) {
|
||||||
|
this.rootStore.log.error('Failed to fetch user items', err)
|
||||||
|
}
|
||||||
|
if (loadMoreErr) {
|
||||||
|
this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper functions
|
||||||
|
// =
|
||||||
|
|
||||||
|
_replaceAll(res: GetList.Response) {
|
||||||
|
this.items = []
|
||||||
|
this._appendAll(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
_appendAll(res: GetList.Response) {
|
||||||
|
this.loadMoreCursor = res.data.cursor
|
||||||
|
this.hasMore = !!this.loadMoreCursor
|
||||||
|
this.list = res.data.list
|
||||||
|
this.items = this.items.concat(
|
||||||
|
res.data.items.map(item => ({...item, _reactKey: item.subject})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||||
import {
|
import {
|
||||||
getEmbedLabels,
|
getEmbedLabels,
|
||||||
getEmbedMuted,
|
getEmbedMuted,
|
||||||
|
getEmbedMutedByList,
|
||||||
getEmbedBlocking,
|
getEmbedBlocking,
|
||||||
getEmbedBlockedBy,
|
getEmbedBlockedBy,
|
||||||
filterAccountLabels,
|
filterAccountLabels,
|
||||||
|
@ -70,6 +71,9 @@ export class PostThreadItemModel {
|
||||||
this.post.author.viewer?.muted ||
|
this.post.author.viewer?.muted ||
|
||||||
getEmbedMuted(this.post.embed) ||
|
getEmbedMuted(this.post.embed) ||
|
||||||
false,
|
false,
|
||||||
|
mutedByList:
|
||||||
|
this.post.author.viewer?.mutedByList ||
|
||||||
|
getEmbedMutedByList(this.post.embed),
|
||||||
isBlocking:
|
isBlocking:
|
||||||
!!this.post.author.viewer?.blocking ||
|
!!this.post.author.viewer?.blocking ||
|
||||||
getEmbedBlocking(this.post.embed) ||
|
getEmbedBlocking(this.post.embed) ||
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {
|
import {
|
||||||
AtUri,
|
AtUri,
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
|
AppBskyGraphDefs,
|
||||||
AppBskyActorGetProfile as GetProfile,
|
AppBskyActorGetProfile as GetProfile,
|
||||||
AppBskyActorProfile,
|
AppBskyActorProfile,
|
||||||
RichText,
|
RichText,
|
||||||
|
@ -18,10 +19,9 @@ import {
|
||||||
filterProfileLabels,
|
filterProfileLabels,
|
||||||
} from 'lib/labeling/helpers'
|
} from 'lib/labeling/helpers'
|
||||||
|
|
||||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
|
||||||
|
|
||||||
export class ProfileViewerModel {
|
export class ProfileViewerModel {
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
|
mutedByList?: AppBskyGraphDefs.ListViewBasic
|
||||||
following?: string
|
following?: string
|
||||||
followedBy?: string
|
followedBy?: string
|
||||||
blockedBy?: boolean
|
blockedBy?: boolean
|
||||||
|
|
|
@ -111,6 +111,7 @@ export class NotificationsFeedItemModel {
|
||||||
addedInfo?.profileLabels || [],
|
addedInfo?.profileLabels || [],
|
||||||
),
|
),
|
||||||
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
|
isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
|
||||||
|
mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
|
||||||
isBlocking:
|
isBlocking:
|
||||||
!!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
|
!!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
|
||||||
isBlockedBy:
|
isBlockedBy:
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||||
import {
|
import {
|
||||||
getEmbedLabels,
|
getEmbedLabels,
|
||||||
getEmbedMuted,
|
getEmbedMuted,
|
||||||
|
getEmbedMutedByList,
|
||||||
getEmbedBlocking,
|
getEmbedBlocking,
|
||||||
getEmbedBlockedBy,
|
getEmbedBlockedBy,
|
||||||
getPostModeration,
|
getPostModeration,
|
||||||
|
@ -105,6 +106,9 @@ export class PostsFeedItemModel {
|
||||||
this.post.author.viewer?.muted ||
|
this.post.author.viewer?.muted ||
|
||||||
getEmbedMuted(this.post.embed) ||
|
getEmbedMuted(this.post.embed) ||
|
||||||
false,
|
false,
|
||||||
|
mutedByList:
|
||||||
|
this.post.author.viewer?.mutedByList ||
|
||||||
|
getEmbedMutedByList(this.post.embed),
|
||||||
isBlocking:
|
isBlocking:
|
||||||
!!this.post.author.viewer?.blocking ||
|
!!this.post.author.viewer?.blocking ||
|
||||||
getEmbedBlocking(this.post.embed) ||
|
getEmbedBlocking(this.post.embed) ||
|
||||||
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
import {makeAutoObservable} from 'mobx'
|
||||||
|
import {
|
||||||
|
AppBskyGraphGetLists as GetLists,
|
||||||
|
AppBskyGraphGetListMutes as GetListMutes,
|
||||||
|
AppBskyGraphDefs as GraphDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {RootStoreModel} from '../root-store'
|
||||||
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
|
export class ListsListModel {
|
||||||
|
// state
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
hasLoaded = false
|
||||||
|
error = ''
|
||||||
|
loadMoreError = ''
|
||||||
|
hasMore = true
|
||||||
|
loadMoreCursor?: string
|
||||||
|
|
||||||
|
// data
|
||||||
|
lists: GraphDefs.ListView[] = []
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public rootStore: RootStoreModel,
|
||||||
|
public source: 'my-modlists' | string,
|
||||||
|
) {
|
||||||
|
makeAutoObservable(
|
||||||
|
this,
|
||||||
|
{
|
||||||
|
rootStore: false,
|
||||||
|
},
|
||||||
|
{autoBind: true},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasContent() {
|
||||||
|
return this.lists.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasError() {
|
||||||
|
return this.error !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return this.hasLoaded && !this.hasContent
|
||||||
|
}
|
||||||
|
|
||||||
|
// public api
|
||||||
|
// =
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
return this.loadMore(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||||
|
if (!replace && !this.hasMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._xLoading(replace)
|
||||||
|
try {
|
||||||
|
let res
|
||||||
|
if (this.source === 'my-modlists') {
|
||||||
|
res = {
|
||||||
|
success: true,
|
||||||
|
headers: {},
|
||||||
|
data: {
|
||||||
|
subject: undefined,
|
||||||
|
lists: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const [res1, res2] = await Promise.all([
|
||||||
|
fetchAllUserLists(this.rootStore, this.rootStore.me.did),
|
||||||
|
fetchAllMyMuteLists(this.rootStore),
|
||||||
|
])
|
||||||
|
for (let list of res1.data.lists) {
|
||||||
|
if (list.purpose === 'app.bsky.graph.defs#modlist') {
|
||||||
|
res.data.lists.push(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let list of res2.data.lists) {
|
||||||
|
if (
|
||||||
|
list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||||
|
!res.data.lists.find(l => l.uri === list.uri)
|
||||||
|
) {
|
||||||
|
res.data.lists.push(list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = await this.rootStore.agent.app.bsky.graph.getLists({
|
||||||
|
actor: this.source,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
cursor: replace ? undefined : this.loadMoreCursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (replace) {
|
||||||
|
this._replaceAll(res)
|
||||||
|
} else {
|
||||||
|
this._appendAll(res)
|
||||||
|
}
|
||||||
|
this._xIdle()
|
||||||
|
} catch (e: any) {
|
||||||
|
this._xIdle(replace ? e : undefined, !replace ? e : undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to load more again after a failure
|
||||||
|
*/
|
||||||
|
async retryLoadMore() {
|
||||||
|
this.loadMoreError = ''
|
||||||
|
this.hasMore = true
|
||||||
|
return this.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// state transitions
|
||||||
|
// =
|
||||||
|
|
||||||
|
_xLoading(isRefreshing = false) {
|
||||||
|
this.isLoading = true
|
||||||
|
this.isRefreshing = isRefreshing
|
||||||
|
this.error = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
_xIdle(err?: any, loadMoreErr?: any) {
|
||||||
|
this.isLoading = false
|
||||||
|
this.isRefreshing = false
|
||||||
|
this.hasLoaded = true
|
||||||
|
this.error = cleanError(err)
|
||||||
|
this.loadMoreError = cleanError(loadMoreErr)
|
||||||
|
if (err) {
|
||||||
|
this.rootStore.log.error('Failed to fetch user lists', err)
|
||||||
|
}
|
||||||
|
if (loadMoreErr) {
|
||||||
|
this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper functions
|
||||||
|
// =
|
||||||
|
|
||||||
|
_replaceAll(res: GetLists.Response | GetListMutes.Response) {
|
||||||
|
this.lists = []
|
||||||
|
this._appendAll(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
_appendAll(res: GetLists.Response | GetListMutes.Response) {
|
||||||
|
this.loadMoreCursor = res.data.cursor
|
||||||
|
this.hasMore = !!this.loadMoreCursor
|
||||||
|
this.lists = this.lists.concat(
|
||||||
|
res.data.lists.map(list => ({...list, _reactKey: list.uri})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllUserLists(
|
||||||
|
store: RootStoreModel,
|
||||||
|
did: string,
|
||||||
|
): Promise<GetLists.Response> {
|
||||||
|
let acc: GetLists.Response = {
|
||||||
|
success: true,
|
||||||
|
headers: {},
|
||||||
|
data: {
|
||||||
|
subject: undefined,
|
||||||
|
lists: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const res = await store.agent.app.bsky.graph.getLists({
|
||||||
|
actor: did,
|
||||||
|
cursor,
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
cursor = res.data.cursor
|
||||||
|
acc.data.lists = acc.data.lists.concat(res.data.lists)
|
||||||
|
if (!cursor) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllMyMuteLists(
|
||||||
|
store: RootStoreModel,
|
||||||
|
): Promise<GetListMutes.Response> {
|
||||||
|
let acc: GetListMutes.Response = {
|
||||||
|
success: true,
|
||||||
|
headers: {},
|
||||||
|
data: {
|
||||||
|
subject: undefined,
|
||||||
|
lists: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const res = await store.agent.app.bsky.graph.getListMutes({
|
||||||
|
cursor,
|
||||||
|
limit: 50,
|
||||||
|
})
|
||||||
|
cursor = res.data.cursor
|
||||||
|
acc.data.lists = acc.data.lists.concat(res.data.lists)
|
||||||
|
if (!cursor) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}
|
|
@ -2,13 +2,19 @@ import {makeAutoObservable} from 'mobx'
|
||||||
import {RootStoreModel} from '../root-store'
|
import {RootStoreModel} from '../root-store'
|
||||||
import {ProfileModel} from '../content/profile'
|
import {ProfileModel} from '../content/profile'
|
||||||
import {PostsFeedModel} from '../feeds/posts'
|
import {PostsFeedModel} from '../feeds/posts'
|
||||||
|
import {ListsListModel} from '../lists/lists-list'
|
||||||
|
|
||||||
export enum Sections {
|
export enum Sections {
|
||||||
Posts = 'Posts',
|
Posts = 'Posts',
|
||||||
PostsWithReplies = 'Posts & replies',
|
PostsWithReplies = 'Posts & replies',
|
||||||
|
Lists = 'Lists',
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies]
|
const USER_SELECTOR_ITEMS = [
|
||||||
|
Sections.Posts,
|
||||||
|
Sections.PostsWithReplies,
|
||||||
|
Sections.Lists,
|
||||||
|
]
|
||||||
|
|
||||||
export interface ProfileUiParams {
|
export interface ProfileUiParams {
|
||||||
user: string
|
user: string
|
||||||
|
@ -22,6 +28,7 @@ export class ProfileUiModel {
|
||||||
// data
|
// data
|
||||||
profile: ProfileModel
|
profile: ProfileModel
|
||||||
feed: PostsFeedModel
|
feed: PostsFeedModel
|
||||||
|
lists: ListsListModel
|
||||||
|
|
||||||
// ui state
|
// ui state
|
||||||
selectedViewIndex = 0
|
selectedViewIndex = 0
|
||||||
|
@ -43,14 +50,17 @@ export class ProfileUiModel {
|
||||||
actor: params.user,
|
actor: params.user,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
|
this.lists = new ListsListModel(rootStore, params.user)
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentView(): PostsFeedModel {
|
get currentView(): PostsFeedModel | ListsListModel {
|
||||||
if (
|
if (
|
||||||
this.selectedView === Sections.Posts ||
|
this.selectedView === Sections.Posts ||
|
||||||
this.selectedView === Sections.PostsWithReplies
|
this.selectedView === Sections.PostsWithReplies
|
||||||
) {
|
) {
|
||||||
return this.feed
|
return this.feed
|
||||||
|
} else if (this.selectedView === Sections.Lists) {
|
||||||
|
return this.lists
|
||||||
}
|
}
|
||||||
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
|
||||||
}
|
}
|
||||||
|
@ -100,6 +110,12 @@ export class ProfileUiModel {
|
||||||
} else if (this.feed.isEmpty) {
|
} else if (this.feed.isEmpty) {
|
||||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||||
}
|
}
|
||||||
|
} else if (this.selectedView === Sections.Lists) {
|
||||||
|
if (this.lists.hasContent) {
|
||||||
|
arr = this.lists.lists
|
||||||
|
} else if (this.lists.isEmpty) {
|
||||||
|
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
|
||||||
}
|
}
|
||||||
|
@ -113,6 +129,8 @@ export class ProfileUiModel {
|
||||||
this.selectedView === Sections.PostsWithReplies
|
this.selectedView === Sections.PostsWithReplies
|
||||||
) {
|
) {
|
||||||
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
|
return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
|
||||||
|
} else if (this.selectedView === Sections.Lists) {
|
||||||
|
return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -133,6 +151,11 @@ export class ProfileUiModel {
|
||||||
.setup()
|
.setup()
|
||||||
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
|
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
|
||||||
])
|
])
|
||||||
|
// HACK: need to use the DID as a param, not the username -prf
|
||||||
|
this.lists.source = this.profile.did
|
||||||
|
this.lists
|
||||||
|
.loadMore()
|
||||||
|
.catch(err => this.rootStore.log.error('Failed to fetch lists', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
async update() {
|
async update() {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {ImageModel} from '../media/image'
|
import {ImageModel} from '../media/image'
|
||||||
|
import {ListModel} from '../content/list'
|
||||||
import {GalleryModel} from '../media/gallery'
|
import {GalleryModel} from '../media/gallery'
|
||||||
|
|
||||||
export interface ConfirmModal {
|
export interface ConfirmModal {
|
||||||
|
@ -38,6 +39,19 @@ export interface ReportAccountModal {
|
||||||
did: string
|
did: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateOrEditMuteListModal {
|
||||||
|
name: 'create-or-edit-mute-list'
|
||||||
|
list?: ListModel
|
||||||
|
onSave?: (uri: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListAddRemoveUserModal {
|
||||||
|
name: 'list-add-remove-user'
|
||||||
|
subject: string
|
||||||
|
displayName: string
|
||||||
|
onUpdate?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface EditImageModal {
|
export interface EditImageModal {
|
||||||
name: 'edit-image'
|
name: 'edit-image'
|
||||||
image: ImageModel
|
image: ImageModel
|
||||||
|
@ -102,9 +116,11 @@ export type Modal =
|
||||||
| ContentFilteringSettingsModal
|
| ContentFilteringSettingsModal
|
||||||
| ContentLanguagesSettingsModal
|
| ContentLanguagesSettingsModal
|
||||||
|
|
||||||
// Reporting
|
// Moderation
|
||||||
| ReportAccountModal
|
| ReportAccountModal
|
||||||
| ReportPostModal
|
| ReportPostModal
|
||||||
|
| CreateMuteListModal
|
||||||
|
| ListAddRemoveUserModal
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
| AltTextImageModal
|
| AltTextImageModal
|
||||||
|
|
|
@ -0,0 +1,155 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||||
|
import {Link} from '../util/Link'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
|
||||||
|
export const ListCard = ({
|
||||||
|
testID,
|
||||||
|
list,
|
||||||
|
noBg,
|
||||||
|
noBorder,
|
||||||
|
renderButton,
|
||||||
|
}: {
|
||||||
|
testID?: string
|
||||||
|
list: AppBskyGraphDefs.ListView
|
||||||
|
noBg?: boolean
|
||||||
|
noBorder?: boolean
|
||||||
|
renderButton?: () => JSX.Element
|
||||||
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
|
||||||
|
const rkey = React.useMemo(() => {
|
||||||
|
try {
|
||||||
|
const urip = new AtUri(list.uri)
|
||||||
|
return urip.rkey
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [list])
|
||||||
|
|
||||||
|
const descriptionRichText = React.useMemo(() => {
|
||||||
|
if (list.description) {
|
||||||
|
return new RichText({
|
||||||
|
text: list.description,
|
||||||
|
facets: list.descriptionFacets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [list])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
testID={testID}
|
||||||
|
style={[
|
||||||
|
styles.outer,
|
||||||
|
pal.border,
|
||||||
|
noBorder && styles.outerNoBorder,
|
||||||
|
!noBg && pal.view,
|
||||||
|
]}
|
||||||
|
href={`/profile/${list.creator.did}/lists/${rkey}`}
|
||||||
|
title={list.name}
|
||||||
|
asAnchor
|
||||||
|
anchorNoUnderline>
|
||||||
|
<View style={styles.layout}>
|
||||||
|
<View style={styles.layoutAvi}>
|
||||||
|
<UserAvatar size={40} avatar={list.avatar} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.layoutContent}>
|
||||||
|
<Text
|
||||||
|
type="lg"
|
||||||
|
style={[s.bold, pal.text]}
|
||||||
|
numberOfLines={1}
|
||||||
|
lineHeight={1.2}>
|
||||||
|
{sanitizeDisplayName(list.name)}
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
|
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
|
||||||
|
{list.creator.did === store.me.did
|
||||||
|
? 'you'
|
||||||
|
: `@${list.creator.handle}`}
|
||||||
|
</Text>
|
||||||
|
{!!list.viewer?.muted && (
|
||||||
|
<View style={s.flexRow}>
|
||||||
|
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||||
|
<Text type="xs" style={pal.text}>
|
||||||
|
Subscribed
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{renderButton ? (
|
||||||
|
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
{descriptionRichText ? (
|
||||||
|
<View style={styles.details}>
|
||||||
|
<RichTextCom
|
||||||
|
style={pal.text}
|
||||||
|
numberOfLines={20}
|
||||||
|
richText={descriptionRichText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
outer: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
outerNoBorder: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
layoutAvi: {
|
||||||
|
width: 54,
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
resizeMode: 'cover',
|
||||||
|
},
|
||||||
|
layoutContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
layoutButton: {
|
||||||
|
paddingRight: 10,
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
paddingLeft: 54,
|
||||||
|
paddingRight: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 50,
|
||||||
|
marginLeft: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
})
|
|
@ -0,0 +1,387 @@
|
||||||
|
import React, {MutableRefObject} from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FlatList} from '../util/Views'
|
||||||
|
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||||
|
import {ProfileCard} from '../profile/ProfileCard'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {RichText as RichTextCom} from '../util/text/RichText'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
import {TextLink} from '../util/Link'
|
||||||
|
import {ListModel} from 'state/models/content/list'
|
||||||
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||||
|
const HEADER_ITEM = {_reactKey: '__header__'}
|
||||||
|
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||||
|
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||||
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
|
|
||||||
|
export const ListItems = observer(
|
||||||
|
({
|
||||||
|
list,
|
||||||
|
style,
|
||||||
|
scrollElRef,
|
||||||
|
onPressTryAgain,
|
||||||
|
onToggleSubscribed,
|
||||||
|
onPressEditList,
|
||||||
|
onPressDeleteList,
|
||||||
|
renderEmptyState,
|
||||||
|
testID,
|
||||||
|
headerOffset = 0,
|
||||||
|
}: {
|
||||||
|
list: ListModel
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
|
onPressTryAgain?: () => void
|
||||||
|
onToggleSubscribed?: () => void
|
||||||
|
onPressEditList?: () => void
|
||||||
|
onPressDeleteList?: () => void
|
||||||
|
renderEmptyState?: () => JSX.Element
|
||||||
|
testID?: string
|
||||||
|
headerOffset?: number
|
||||||
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
|
|
||||||
|
const data = React.useMemo(() => {
|
||||||
|
let items: any[] = [HEADER_ITEM]
|
||||||
|
if (list.hasLoaded) {
|
||||||
|
if (list.hasError) {
|
||||||
|
items = items.concat([ERROR_ITEM])
|
||||||
|
}
|
||||||
|
if (list.isEmpty) {
|
||||||
|
items = items.concat([EMPTY_ITEM])
|
||||||
|
} else {
|
||||||
|
items = items.concat(list.items)
|
||||||
|
}
|
||||||
|
if (list.loadMoreError) {
|
||||||
|
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||||
|
}
|
||||||
|
} else if (list.isLoading) {
|
||||||
|
items = items.concat([LOADING_ITEM])
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
list.hasError,
|
||||||
|
list.hasLoaded,
|
||||||
|
list.isLoading,
|
||||||
|
list.isEmpty,
|
||||||
|
list.items,
|
||||||
|
list.loadMoreError,
|
||||||
|
])
|
||||||
|
|
||||||
|
// events
|
||||||
|
// =
|
||||||
|
|
||||||
|
const onRefresh = React.useCallback(async () => {
|
||||||
|
track('Lists:onRefresh')
|
||||||
|
setIsRefreshing(true)
|
||||||
|
try {
|
||||||
|
await list.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
list.rootStore.log.error('Failed to refresh lists', err)
|
||||||
|
}
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}, [list, track, setIsRefreshing])
|
||||||
|
|
||||||
|
const onEndReached = React.useCallback(async () => {
|
||||||
|
track('Lists:onEndReached')
|
||||||
|
try {
|
||||||
|
await list.loadMore()
|
||||||
|
} catch (err) {
|
||||||
|
list.rootStore.log.error('Failed to load more lists', err)
|
||||||
|
}
|
||||||
|
}, [list, track])
|
||||||
|
|
||||||
|
const onPressRetryLoadMore = React.useCallback(() => {
|
||||||
|
list.retryLoadMore()
|
||||||
|
}, [list])
|
||||||
|
|
||||||
|
const onPressEditMembership = React.useCallback(
|
||||||
|
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'list-add-remove-user',
|
||||||
|
subject: profile.did,
|
||||||
|
displayName: profile.displayName || profile.handle,
|
||||||
|
onUpdate() {
|
||||||
|
list.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[store, list],
|
||||||
|
)
|
||||||
|
|
||||||
|
// rendering
|
||||||
|
// =
|
||||||
|
|
||||||
|
const renderMemberButton = React.useCallback(
|
||||||
|
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||||
|
if (!list.isOwner) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
label="Edit"
|
||||||
|
onPress={() => onPressEditMembership(profile)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[list, onPressEditMembership],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderItem = React.useCallback(
|
||||||
|
({item}: {item: any}) => {
|
||||||
|
if (item === EMPTY_ITEM) {
|
||||||
|
if (renderEmptyState) {
|
||||||
|
return renderEmptyState()
|
||||||
|
}
|
||||||
|
return <View />
|
||||||
|
} else if (item === HEADER_ITEM) {
|
||||||
|
return list.list ? (
|
||||||
|
<ListHeader
|
||||||
|
list={list.list}
|
||||||
|
isOwner={list.isOwner}
|
||||||
|
onToggleSubscribed={onToggleSubscribed}
|
||||||
|
onPressEditList={onPressEditList}
|
||||||
|
onPressDeleteList={onPressDeleteList}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
} else if (item === ERROR_ITEM) {
|
||||||
|
return (
|
||||||
|
<ErrorMessage
|
||||||
|
message={list.error}
|
||||||
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||||
|
return (
|
||||||
|
<LoadMoreRetryBtn
|
||||||
|
label="There was an issue fetching the list. Tap here to try again."
|
||||||
|
onPress={onPressRetryLoadMore}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (item === LOADING_ITEM) {
|
||||||
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ProfileCard
|
||||||
|
testID={`user-${
|
||||||
|
(item as AppBskyGraphDefs.ListItemView).subject.handle
|
||||||
|
}`}
|
||||||
|
profile={(item as AppBskyGraphDefs.ListItemView).subject}
|
||||||
|
renderButton={renderMemberButton}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
list,
|
||||||
|
onPressTryAgain,
|
||||||
|
onPressRetryLoadMore,
|
||||||
|
renderMemberButton,
|
||||||
|
onPressEditList,
|
||||||
|
onPressDeleteList,
|
||||||
|
onToggleSubscribed,
|
||||||
|
renderEmptyState,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const Footer = React.useCallback(
|
||||||
|
() =>
|
||||||
|
list.isLoading ? (
|
||||||
|
<View style={styles.feedFooter}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
),
|
||||||
|
[list],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={style}>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<FlatList
|
||||||
|
testID={testID ? `${testID}-flatlist` : undefined}
|
||||||
|
ref={scrollElRef}
|
||||||
|
data={data}
|
||||||
|
keyExtractor={item => item._reactKey}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ListFooterComponent={Footer}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={pal.colors.text}
|
||||||
|
titleColor={pal.colors.text}
|
||||||
|
progressViewOffset={headerOffset}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={s.contentContainer}
|
||||||
|
style={{paddingTop: headerOffset}}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={0.6}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||||
|
// @ts-ignore our .web version only -prf
|
||||||
|
desktopFixedHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const ListHeader = observer(
|
||||||
|
({
|
||||||
|
list,
|
||||||
|
isOwner,
|
||||||
|
onToggleSubscribed,
|
||||||
|
onPressEditList,
|
||||||
|
onPressDeleteList,
|
||||||
|
}: {
|
||||||
|
list: AppBskyGraphDefs.ListView
|
||||||
|
isOwner: boolean
|
||||||
|
onToggleSubscribed?: () => void
|
||||||
|
onPressEditList?: () => void
|
||||||
|
onPressDeleteList?: () => void
|
||||||
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const descriptionRT = React.useMemo(
|
||||||
|
() =>
|
||||||
|
list?.description &&
|
||||||
|
new RichText({text: list.description, facets: list.descriptionFacets}),
|
||||||
|
[list],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={[styles.header, pal.border]}>
|
||||||
|
<View style={s.flex1}>
|
||||||
|
<Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
|
||||||
|
{list.name}
|
||||||
|
</Text>
|
||||||
|
{list && (
|
||||||
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
|
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
|
||||||
|
by{' '}
|
||||||
|
{list.creator.did === store.me.did ? (
|
||||||
|
'you'
|
||||||
|
) : (
|
||||||
|
<TextLink
|
||||||
|
text={`@${list.creator.handle}`}
|
||||||
|
href={`/profile/${list.creator.did}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{descriptionRT && (
|
||||||
|
<RichTextCom
|
||||||
|
testID="listDescription"
|
||||||
|
style={[pal.text, styles.headerDescription]}
|
||||||
|
richText={descriptionRT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isDesktopWeb && (
|
||||||
|
<View style={styles.headerBtns}>
|
||||||
|
{list.viewer?.muted ? (
|
||||||
|
<Button
|
||||||
|
type="inverted"
|
||||||
|
label="Unsubscribe"
|
||||||
|
accessibilityLabel="Unsubscribe from this list"
|
||||||
|
accessibilityHint="Stops muting the users included in this list"
|
||||||
|
onPress={onToggleSubscribed}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
label="Subscribe & Mute"
|
||||||
|
accessibilityLabel="Subscribe to this list"
|
||||||
|
accessibilityHint="Mutes the users included in this list"
|
||||||
|
onPress={onToggleSubscribed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
label="Edit List"
|
||||||
|
accessibilityLabel="Edit list"
|
||||||
|
accessibilityHint="Opens a modal to edit the mutelist"
|
||||||
|
onPress={onPressEditList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
label="Delete List"
|
||||||
|
accessibilityLabel="Delete list"
|
||||||
|
accessibilityHint="Deletes the mutelist"
|
||||||
|
onPress={onPressDeleteList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<UserAvatar avatar={list.avatar} size={64} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.fakeSelector, pal.border]}>
|
||||||
|
<View
|
||||||
|
style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
|
||||||
|
<Text type="md-medium" style={[pal.text]}>
|
||||||
|
Muted users
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
headerDescription: {
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
headerBtns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
fakeSelector: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: isDesktopWeb ? 16 : 6,
|
||||||
|
},
|
||||||
|
fakeSelectorItem: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottomWidth: 3,
|
||||||
|
},
|
||||||
|
feedFooter: {paddingTop: 20},
|
||||||
|
})
|
|
@ -0,0 +1,240 @@
|
||||||
|
import React, {MutableRefObject} from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
StyleProp,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||||
|
import {FlatList} from '../util/Views'
|
||||||
|
import {ListCard} from './ListCard'
|
||||||
|
import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||||
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
|
||||||
|
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||||
|
const CREATENEW_ITEM = {_reactKey: '__loading__'}
|
||||||
|
const EMPTY_ITEM = {_reactKey: '__empty__'}
|
||||||
|
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||||
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
|
|
||||||
|
export const ListsList = observer(
|
||||||
|
({
|
||||||
|
listsList,
|
||||||
|
showAddBtns,
|
||||||
|
style,
|
||||||
|
scrollElRef,
|
||||||
|
onPressTryAgain,
|
||||||
|
onPressCreateNew,
|
||||||
|
renderItem,
|
||||||
|
renderEmptyState,
|
||||||
|
testID,
|
||||||
|
headerOffset = 0,
|
||||||
|
}: {
|
||||||
|
listsList: ListsListModel
|
||||||
|
showAddBtns?: boolean
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
|
onPressCreateNew: () => void
|
||||||
|
onPressTryAgain?: () => void
|
||||||
|
renderItem?: (list: GraphDefs.ListView) => JSX.Element
|
||||||
|
renderEmptyState?: () => JSX.Element
|
||||||
|
testID?: string
|
||||||
|
headerOffset?: number
|
||||||
|
}) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
|
|
||||||
|
const data = React.useMemo(() => {
|
||||||
|
let items: any[] = []
|
||||||
|
if (listsList.hasLoaded) {
|
||||||
|
if (listsList.hasError) {
|
||||||
|
items = items.concat([ERROR_ITEM])
|
||||||
|
}
|
||||||
|
if (listsList.isEmpty) {
|
||||||
|
items = items.concat([EMPTY_ITEM])
|
||||||
|
} else {
|
||||||
|
if (showAddBtns) {
|
||||||
|
items = items.concat([CREATENEW_ITEM])
|
||||||
|
}
|
||||||
|
items = items.concat(listsList.lists)
|
||||||
|
}
|
||||||
|
if (listsList.loadMoreError) {
|
||||||
|
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||||
|
}
|
||||||
|
} else if (listsList.isLoading) {
|
||||||
|
items = items.concat([LOADING_ITEM])
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [
|
||||||
|
listsList.hasError,
|
||||||
|
listsList.hasLoaded,
|
||||||
|
listsList.isLoading,
|
||||||
|
listsList.isEmpty,
|
||||||
|
listsList.lists,
|
||||||
|
listsList.loadMoreError,
|
||||||
|
showAddBtns,
|
||||||
|
])
|
||||||
|
|
||||||
|
// events
|
||||||
|
// =
|
||||||
|
|
||||||
|
const onRefresh = React.useCallback(async () => {
|
||||||
|
track('Lists:onRefresh')
|
||||||
|
setIsRefreshing(true)
|
||||||
|
try {
|
||||||
|
await listsList.refresh()
|
||||||
|
} catch (err) {
|
||||||
|
listsList.rootStore.log.error('Failed to refresh lists', err)
|
||||||
|
}
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}, [listsList, track, setIsRefreshing])
|
||||||
|
|
||||||
|
const onEndReached = React.useCallback(async () => {
|
||||||
|
track('Lists:onEndReached')
|
||||||
|
try {
|
||||||
|
await listsList.loadMore()
|
||||||
|
} catch (err) {
|
||||||
|
listsList.rootStore.log.error('Failed to load more lists', err)
|
||||||
|
}
|
||||||
|
}, [listsList, track])
|
||||||
|
|
||||||
|
const onPressRetryLoadMore = React.useCallback(() => {
|
||||||
|
listsList.retryLoadMore()
|
||||||
|
}, [listsList])
|
||||||
|
|
||||||
|
// rendering
|
||||||
|
// =
|
||||||
|
|
||||||
|
const renderItemInner = React.useCallback(
|
||||||
|
({item}: {item: any}) => {
|
||||||
|
if (item === EMPTY_ITEM) {
|
||||||
|
if (renderEmptyState) {
|
||||||
|
return renderEmptyState()
|
||||||
|
}
|
||||||
|
return <View />
|
||||||
|
} else if (item === CREATENEW_ITEM) {
|
||||||
|
return <CreateNewItem onPress={onPressCreateNew} />
|
||||||
|
} else if (item === ERROR_ITEM) {
|
||||||
|
return (
|
||||||
|
<ErrorMessage
|
||||||
|
message={listsList.error}
|
||||||
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||||
|
return (
|
||||||
|
<LoadMoreRetryBtn
|
||||||
|
label="There was an issue fetching your lists. Tap here to try again."
|
||||||
|
onPress={onPressRetryLoadMore}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (item === LOADING_ITEM) {
|
||||||
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
|
}
|
||||||
|
return renderItem ? (
|
||||||
|
renderItem(item)
|
||||||
|
) : (
|
||||||
|
<ListCard list={item} testID={`list-${item.name}`} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
listsList,
|
||||||
|
onPressTryAgain,
|
||||||
|
onPressRetryLoadMore,
|
||||||
|
onPressCreateNew,
|
||||||
|
renderItem,
|
||||||
|
renderEmptyState,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const Footer = React.useCallback(
|
||||||
|
() =>
|
||||||
|
listsList.isLoading ? (
|
||||||
|
<View style={styles.feedFooter}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
),
|
||||||
|
[listsList],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={testID} style={style}>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<FlatList
|
||||||
|
testID={testID ? `${testID}-flatlist` : undefined}
|
||||||
|
ref={scrollElRef}
|
||||||
|
data={data}
|
||||||
|
keyExtractor={item => item._reactKey}
|
||||||
|
renderItem={renderItemInner}
|
||||||
|
ListFooterComponent={Footer}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={pal.colors.text}
|
||||||
|
titleColor={pal.colors.text}
|
||||||
|
progressViewOffset={headerOffset}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={s.contentContainer}
|
||||||
|
style={{paddingTop: headerOffset}}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={0.6}
|
||||||
|
removeClippedSubviews={true}
|
||||||
|
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||||
|
// @ts-ignore our .web version only -prf
|
||||||
|
desktopFixedHeight
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function CreateNewItem({onPress}: {onPress: () => void}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.createNewContainer]}>
|
||||||
|
<Button type="default" onPress={onPress} style={styles.createNewButton}>
|
||||||
|
<FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
|
||||||
|
<Text type="button" style={pal.text}>
|
||||||
|
New Mute List
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
createNewContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingTop: 18,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
createNewButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
feedFooter: {paddingTop: 20},
|
||||||
|
})
|
|
@ -21,8 +21,8 @@ export function Component({}: {}) {
|
||||||
}, [store])
|
}, [store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID="contentModerationModal" style={[pal.view, styles.container]}>
|
<View testID="contentFilteringModal" style={[pal.view, styles.container]}>
|
||||||
<Text style={[pal.text, styles.title]}>Content Moderation</Text>
|
<Text style={[pal.text, styles.title]}>Content Filtering</Text>
|
||||||
<ScrollView style={styles.scrollContainer}>
|
<ScrollView style={styles.scrollContainer}>
|
||||||
<ContentLabelPref
|
<ContentLabelPref
|
||||||
group="nsfw"
|
group="nsfw"
|
||||||
|
@ -50,7 +50,7 @@ export function Component({}: {}) {
|
||||||
testID="sendReportBtn"
|
testID="sendReportBtn"
|
||||||
onPress={onPressDone}
|
onPress={onPressDone}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Confirm content moderation settings"
|
accessibilityLabel="Confirm content filtering settings"
|
||||||
accessibilityHint="">
|
accessibilityHint="">
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||||
|
|
|
@ -0,0 +1,273 @@
|
||||||
|
import React, {useState, useCallback} from 'react'
|
||||||
|
import * as Toast from '../util/Toast'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {ListModel} from 'state/models/content/list'
|
||||||
|
import {s, colors, gradients} from 'lib/styles'
|
||||||
|
import {enforceLen} from 'lib/strings/helpers'
|
||||||
|
import {compressIfNeeded} from 'lib/media/manip'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
import {cleanError, isNetworkError} from 'lib/strings/errors'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
const MAX_NAME = 64 // todo
|
||||||
|
const MAX_DESCRIPTION = 300 // todo
|
||||||
|
|
||||||
|
export const snapPoints = ['fullscreen']
|
||||||
|
|
||||||
|
export function Component({
|
||||||
|
onSave,
|
||||||
|
list,
|
||||||
|
}: {
|
||||||
|
onSave?: (uri: string) => void
|
||||||
|
list?: ListModel
|
||||||
|
}) {
|
||||||
|
const store = useStores()
|
||||||
|
const [error, setError] = useState<string>('')
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const theme = useTheme()
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
|
||||||
|
const [isProcessing, setProcessing] = useState<boolean>(false)
|
||||||
|
const [name, setName] = useState<string>(list?.list.name || '')
|
||||||
|
const [description, setDescription] = useState<string>(
|
||||||
|
list?.list.description || '',
|
||||||
|
)
|
||||||
|
const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar)
|
||||||
|
const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
|
||||||
|
|
||||||
|
const onPressCancel = useCallback(() => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
}, [store])
|
||||||
|
|
||||||
|
const onSelectNewAvatar = useCallback(
|
||||||
|
async (img: RNImage | null) => {
|
||||||
|
if (!img) {
|
||||||
|
setNewAvatar(null)
|
||||||
|
setAvatar(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
track('CreateMuteList:AvatarSelected')
|
||||||
|
try {
|
||||||
|
const finalImg = await compressIfNeeded(img, 1000000)
|
||||||
|
setNewAvatar(finalImg)
|
||||||
|
setAvatar(finalImg.path)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(cleanError(e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[track, setNewAvatar, setAvatar, setError],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressSave = useCallback(async () => {
|
||||||
|
track('CreateMuteList:Save')
|
||||||
|
const nameTrimmed = name.trim()
|
||||||
|
if (!nameTrimmed) {
|
||||||
|
setError('Name is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setProcessing(true)
|
||||||
|
if (error) {
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (list) {
|
||||||
|
await list.updateMetadata({
|
||||||
|
name: nameTrimmed,
|
||||||
|
description: description.trim(),
|
||||||
|
avatar: newAvatar,
|
||||||
|
})
|
||||||
|
Toast.show('Mute list updated')
|
||||||
|
onSave?.(list.uri)
|
||||||
|
} else {
|
||||||
|
const res = await ListModel.createModList(store, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
avatar: newAvatar,
|
||||||
|
})
|
||||||
|
Toast.show('Mute list created')
|
||||||
|
onSave?.(res.uri)
|
||||||
|
}
|
||||||
|
store.shell.closeModal()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
setError(
|
||||||
|
'Failed to create the mute list. Check your internet connection and try again.',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setError(cleanError(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProcessing(false)
|
||||||
|
}, [
|
||||||
|
track,
|
||||||
|
setProcessing,
|
||||||
|
setError,
|
||||||
|
error,
|
||||||
|
onSave,
|
||||||
|
store,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
newAvatar,
|
||||||
|
list,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView behavior="height">
|
||||||
|
<ScrollView
|
||||||
|
style={[pal.view, styles.container]}
|
||||||
|
testID="createOrEditMuteListModal">
|
||||||
|
<Text style={[styles.title, pal.text]}>
|
||||||
|
{list ? 'Edit Mute List' : 'New Mute List'}
|
||||||
|
</Text>
|
||||||
|
{error !== '' && (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={[styles.label, pal.text]}>List Avatar</Text>
|
||||||
|
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
|
||||||
|
<UserAvatar
|
||||||
|
size={80}
|
||||||
|
avatar={avatar}
|
||||||
|
onSelectNewAvatar={onSelectNewAvatar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.form}>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.label, pal.text]}>List Name</Text>
|
||||||
|
<TextInput
|
||||||
|
testID="editNameInput"
|
||||||
|
style={[styles.textInput, pal.border, pal.text]}
|
||||||
|
placeholder="e.g. Spammers"
|
||||||
|
placeholderTextColor={colors.gray4}
|
||||||
|
value={name}
|
||||||
|
onChangeText={v => setName(enforceLen(v, MAX_NAME))}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel="Name"
|
||||||
|
accessibilityHint="Set the list's name"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={s.pb10}>
|
||||||
|
<Text style={[styles.label, pal.text]}>Description</Text>
|
||||||
|
<TextInput
|
||||||
|
testID="editDescriptionInput"
|
||||||
|
style={[styles.textArea, pal.border, pal.text]}
|
||||||
|
placeholder="e.g. Users that repeatedly reply with ads."
|
||||||
|
placeholderTextColor={colors.gray4}
|
||||||
|
keyboardAppearance={theme.colorScheme}
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel="Description"
|
||||||
|
accessibilityHint="Edit your list's description"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{isProcessing ? (
|
||||||
|
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="saveBtn"
|
||||||
|
style={s.mt10}
|
||||||
|
onPress={onPressSave}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Save"
|
||||||
|
accessibilityHint="Creates the mute list">
|
||||||
|
<LinearGradient
|
||||||
|
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||||
|
start={{x: 0, y: 0}}
|
||||||
|
end={{x: 1, y: 1}}
|
||||||
|
style={[styles.btn]}>
|
||||||
|
<Text style={[s.white, s.bold]}>Save</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="cancelBtn"
|
||||||
|
style={s.mt5}
|
||||||
|
onPress={onPressCancel}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel creating the mute list"
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={onPressCancel}>
|
||||||
|
<View style={[styles.btn]}>
|
||||||
|
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: isDesktopWeb ? 0 : 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 100,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 32,
|
||||||
|
padding: 10,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
avi: {
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 42,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
errorContainer: {marginTop: 20},
|
||||||
|
})
|
|
@ -0,0 +1,255 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {Pressable, StyleSheet, View} from 'react-native'
|
||||||
|
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
import {ListsList} from '../lists/ListsList'
|
||||||
|
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||||
|
import {ListMembershipModel} from 'state/models/content/list-membership'
|
||||||
|
import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
|
import * as Toast from '../util/Toast'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {isDesktopWeb, isAndroid} from 'platform/detection'
|
||||||
|
|
||||||
|
export const snapPoints = ['fullscreen']
|
||||||
|
|
||||||
|
export const Component = observer(
|
||||||
|
({
|
||||||
|
subject,
|
||||||
|
displayName,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
subject: string
|
||||||
|
displayName: string
|
||||||
|
onUpdate?: () => void
|
||||||
|
}) => {
|
||||||
|
const store = useStores()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const palPrimary = usePalette('primary')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
|
const [selected, setSelected] = React.useState([])
|
||||||
|
|
||||||
|
const listsList: ListsListModel = React.useMemo(
|
||||||
|
() => new ListsListModel(store, store.me.did),
|
||||||
|
[store],
|
||||||
|
)
|
||||||
|
const memberships: ListMembershipModel = React.useMemo(
|
||||||
|
() => new ListMembershipModel(store, subject),
|
||||||
|
[store, subject],
|
||||||
|
)
|
||||||
|
React.useEffect(() => {
|
||||||
|
listsList.refresh()
|
||||||
|
memberships.fetch().then(
|
||||||
|
() => {
|
||||||
|
setSelected(memberships.memberships.map(m => m.value.list))
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
store.log.error('Failed to fetch memberships', {err})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [memberships, listsList, store, setSelected])
|
||||||
|
|
||||||
|
const onPressCancel = useCallback(() => {
|
||||||
|
store.shell.closeModal()
|
||||||
|
}, [store])
|
||||||
|
|
||||||
|
const onPressSave = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await memberships.updateTo(selected)
|
||||||
|
} catch (err) {
|
||||||
|
store.log.error('Failed to update memberships', {err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Toast.show('Lists updated')
|
||||||
|
onUpdate?.()
|
||||||
|
store.shell.closeModal()
|
||||||
|
}, [store, selected, memberships, onUpdate])
|
||||||
|
|
||||||
|
const onPressNewMuteList = useCallback(() => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'create-or-edit-mute-list',
|
||||||
|
onSave: (_uri: string) => {
|
||||||
|
listsList.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [store, listsList])
|
||||||
|
|
||||||
|
const onToggleSelected = useCallback(
|
||||||
|
(uri: string) => {
|
||||||
|
if (selected.includes(uri)) {
|
||||||
|
setSelected(selected.filter(uri2 => uri2 !== uri))
|
||||||
|
} else {
|
||||||
|
setSelected([...selected, uri])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selected, setSelected],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(list: GraphDefs.ListView) => {
|
||||||
|
const isSelected = selected.includes(list.uri)
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
testID={`toggleBtn-${list.name}`}
|
||||||
|
style={[styles.listItem, pal.border]}
|
||||||
|
accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
|
||||||
|
list.name
|
||||||
|
}`}
|
||||||
|
accessibilityHint="Toggle their inclusion in this list"
|
||||||
|
onPress={() => onToggleSelected(list.uri)}>
|
||||||
|
<View style={styles.listItemAvi}>
|
||||||
|
<UserAvatar size={40} avatar={list.avatar} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.listItemContent}>
|
||||||
|
<Text
|
||||||
|
type="lg"
|
||||||
|
style={[s.bold, pal.text]}
|
||||||
|
numberOfLines={1}
|
||||||
|
lineHeight={1.2}>
|
||||||
|
{sanitizeDisplayName(list.name)}
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
|
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '}
|
||||||
|
by{' '}
|
||||||
|
{list.creator.did === store.me.did
|
||||||
|
? 'you'
|
||||||
|
: `@${list.creator.handle}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={
|
||||||
|
isSelected
|
||||||
|
? [styles.checkbox, palPrimary.border, palPrimary.view]
|
||||||
|
: [styles.checkbox, pal.borderDark]
|
||||||
|
}>
|
||||||
|
{isSelected && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="check"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderEmptyState = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<EmptyStateWithButton
|
||||||
|
icon="users-slash"
|
||||||
|
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
|
||||||
|
buttonLabel="New Mute List"
|
||||||
|
onPress={onPressNewMuteList}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}, [onPressNewMuteList])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID="listAddRemoveUserModal" style={s.hContentRegion}>
|
||||||
|
<Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
|
||||||
|
<ListsList
|
||||||
|
listsList={listsList}
|
||||||
|
showAddBtns
|
||||||
|
onPressCreateNew={onPressNewMuteList}
|
||||||
|
renderItem={renderItem}
|
||||||
|
renderEmptyState={renderEmptyState}
|
||||||
|
style={[styles.list, pal.border]}
|
||||||
|
/>
|
||||||
|
<View style={[styles.btns, pal.border]}>
|
||||||
|
<Button
|
||||||
|
testID="cancelBtn"
|
||||||
|
type="default"
|
||||||
|
onPress={onPressCancel}
|
||||||
|
style={styles.footerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Cancel this modal"
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={onPressCancel}
|
||||||
|
label="Cancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
testID="saveBtn"
|
||||||
|
type="primary"
|
||||||
|
onPress={onPressSave}
|
||||||
|
style={styles.footerBtn}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Save these changes"
|
||||||
|
accessibilityHint=""
|
||||||
|
onAccessibilityEscape={onPressSave}
|
||||||
|
label="Save Changes"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: isDesktopWeb ? 0 : 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
flex: 1,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
btns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: isAndroid ? 10 : 0,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
},
|
||||||
|
footerBtn: {
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
listItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
listItemAvi: {
|
||||||
|
width: 54,
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
listItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
})
|
|
@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile'
|
||||||
import * as ServerInputModal from './ServerInput'
|
import * as ServerInputModal from './ServerInput'
|
||||||
import * as ReportPostModal from './ReportPost'
|
import * as ReportPostModal from './ReportPost'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
|
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||||
|
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||||
import * as AltImageModal from './AltImage'
|
import * as AltImageModal from './AltImage'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
|
@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'report-account') {
|
} else if (activeModal?.name === 'report-account') {
|
||||||
snapPoints = ReportAccountModal.snapPoints
|
snapPoints = ReportAccountModal.snapPoints
|
||||||
element = <ReportAccountModal.Component {...activeModal} />
|
element = <ReportAccountModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'create-or-edit-mute-list') {
|
||||||
|
snapPoints = CreateOrEditMuteListModal.snapPoints
|
||||||
|
element = <CreateOrEditMuteListModal.Component {...activeModal} />
|
||||||
|
} else if (activeModal?.name === 'list-add-remove-user') {
|
||||||
|
snapPoints = ListAddRemoveUserModal.snapPoints
|
||||||
|
element = <ListAddRemoveUserModal.Component {...activeModal} />
|
||||||
} else if (activeModal?.name === 'delete-account') {
|
} else if (activeModal?.name === 'delete-account') {
|
||||||
snapPoints = DeleteAccountModal.snapPoints
|
snapPoints = DeleteAccountModal.snapPoints
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
|
|
|
@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile'
|
||||||
import * as ServerInputModal from './ServerInput'
|
import * as ServerInputModal from './ServerInput'
|
||||||
import * as ReportPostModal from './ReportPost'
|
import * as ReportPostModal from './ReportPost'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
|
import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
|
||||||
|
import * as ListAddRemoveUserModal from './ListAddRemoveUser'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as RepostModal from './Repost'
|
import * as RepostModal from './Repost'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
|
@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <ReportPostModal.Component {...modal} />
|
element = <ReportPostModal.Component {...modal} />
|
||||||
} else if (modal.name === 'report-account') {
|
} else if (modal.name === 'report-account') {
|
||||||
element = <ReportAccountModal.Component {...modal} />
|
element = <ReportAccountModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'create-or-edit-mute-list') {
|
||||||
|
element = <CreateOrEditMuteListModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'list-add-remove-user') {
|
||||||
|
element = <ListAddRemoveUserModal.Component {...modal} />
|
||||||
} else if (modal.name === 'crop-image') {
|
} else if (modal.name === 'crop-image') {
|
||||||
element = <CropImageModal.Component {...modal} />
|
element = <CropImageModal.Component {...modal} />
|
||||||
} else if (modal.name === 'delete-account') {
|
} else if (modal.name === 'delete-account') {
|
||||||
|
|
|
@ -65,7 +65,7 @@ export function TabBar({
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const onLayout = () => {
|
const onLayout = React.useCallback(() => {
|
||||||
const promises = []
|
const promises = []
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
promises.push(
|
promises.push(
|
||||||
|
@ -86,14 +86,17 @@ export function TabBar({
|
||||||
Promise.all(promises).then((layouts: Layout[]) => {
|
Promise.all(promises).then((layouts: Layout[]) => {
|
||||||
setItemLayouts(layouts)
|
setItemLayouts(layouts)
|
||||||
})
|
})
|
||||||
}
|
}, [containerRef, itemRefs, setItemLayouts, items.length])
|
||||||
|
|
||||||
const onPressItem = (index: number) => {
|
const onPressItem = React.useCallback(
|
||||||
|
(index: number) => {
|
||||||
onSelect?.(index)
|
onSelect?.(index)
|
||||||
if (index === selectedPage) {
|
if (index === selectedPage) {
|
||||||
onPressSelected?.()
|
onPressSelected?.()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[onSelect, onPressSelected, selectedPage],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {PostsFeedItemModel} from 'state/models/feeds/posts'
|
import {PostsFeedItemModel} from 'state/models/feeds/posts'
|
||||||
|
import {ModerationBehaviorCode} from 'lib/labeling/types'
|
||||||
import {Link, DesktopWebTextLink} from '../util/Link'
|
import {Link, DesktopWebTextLink} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserInfoText} from '../util/UserInfoText'
|
import {UserInfoText} from '../util/UserInfoText'
|
||||||
|
@ -31,13 +32,14 @@ export const FeedItem = observer(function ({
|
||||||
isThreadChild,
|
isThreadChild,
|
||||||
isThreadParent,
|
isThreadParent,
|
||||||
showFollowBtn,
|
showFollowBtn,
|
||||||
|
ignoreMuteFor,
|
||||||
}: {
|
}: {
|
||||||
item: PostsFeedItemModel
|
item: PostsFeedItemModel
|
||||||
isThreadChild?: boolean
|
isThreadChild?: boolean
|
||||||
isThreadParent?: boolean
|
isThreadParent?: boolean
|
||||||
showReplyLine?: boolean
|
showReplyLine?: boolean
|
||||||
showFollowBtn?: boolean
|
showFollowBtn?: boolean
|
||||||
ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
|
ignoreMuteFor?: string
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -142,12 +144,22 @@ export const FeedItem = observer(function ({
|
||||||
isThreadParent ? styles.outerNoBottom : undefined,
|
isThreadParent ? styles.outerNoBottom : undefined,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// moderation override
|
||||||
|
let moderation = item.moderation.list
|
||||||
|
if (
|
||||||
|
ignoreMuteFor === item.post.author.did &&
|
||||||
|
moderation.isMute &&
|
||||||
|
!moderation.noOverride
|
||||||
|
) {
|
||||||
|
moderation = {behavior: ModerationBehaviorCode.Show}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostHider
|
<PostHider
|
||||||
testID={`feedItem-by-${item.post.author.handle}`}
|
testID={`feedItem-by-${item.post.author.handle}`}
|
||||||
style={outerStyles}
|
style={outerStyles}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
moderation={item.moderation.list}>
|
moderation={moderation}>
|
||||||
{isThreadChild && (
|
{isThreadChild && (
|
||||||
<View
|
<View
|
||||||
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
|
style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
|
||||||
|
@ -237,7 +249,7 @@ export const FeedItem = observer(function ({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<ContentHider
|
<ContentHider
|
||||||
moderation={item.moderation.list}
|
moderation={moderation}
|
||||||
containerStyle={styles.contentHider}>
|
containerStyle={styles.contentHider}>
|
||||||
{item.richText?.text ? (
|
{item.richText?.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
|
|
|
@ -19,8 +19,10 @@ export function FeedSlice({
|
||||||
ignoreMuteFor?: string
|
ignoreMuteFor?: string
|
||||||
}) {
|
}) {
|
||||||
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
|
if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
|
||||||
|
if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
if (slice.isThread && slice.items.length > 3) {
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -32,7 +32,7 @@ export const ProfileCard = observer(
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
followers?: AppBskyActorDefs.ProfileView[] | undefined
|
||||||
overrideModeration?: boolean
|
overrideModeration?: boolean
|
||||||
renderButton?: () => JSX.Element
|
renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element
|
||||||
}) => {
|
}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -92,7 +92,7 @@ export const ProfileCard = observer(
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{renderButton ? (
|
{renderButton ? (
|
||||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</View>
|
</View>
|
||||||
{profile.description ? (
|
{profile.description ? (
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
import {TextLink} from '../util/Link'
|
||||||
import {RichText} from '../util/text/RichText'
|
import {RichText} from '../util/text/RichText'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import {UserBanner} from '../util/UserBanner'
|
import {UserBanner} from '../util/UserBanner'
|
||||||
|
@ -30,6 +31,7 @@ import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {listUriToHref} from 'lib/strings/url-helpers'
|
||||||
import {isDesktopWeb, isNative} from 'platform/detection'
|
import {isDesktopWeb, isNative} from 'platform/detection'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
import {shareUrl} from 'lib/sharing'
|
import {shareUrl} from 'lib/sharing'
|
||||||
|
@ -146,12 +148,21 @@ const ProfileHeaderLoaded = observer(
|
||||||
navigation.push('ProfileFollows', {name: view.handle})
|
navigation.push('ProfileFollows', {name: view.handle})
|
||||||
}, [track, navigation, view])
|
}, [track, navigation, view])
|
||||||
|
|
||||||
const onPressShare = React.useCallback(async () => {
|
const onPressShare = React.useCallback(() => {
|
||||||
track('ProfileHeader:ShareButtonClicked')
|
track('ProfileHeader:ShareButtonClicked')
|
||||||
const url = toShareUrl(`/profile/${view.handle}`)
|
const url = toShareUrl(`/profile/${view.handle}`)
|
||||||
shareUrl(url)
|
shareUrl(url)
|
||||||
}, [track, view])
|
}, [track, view])
|
||||||
|
|
||||||
|
const onPressAddRemoveLists = React.useCallback(() => {
|
||||||
|
track('ProfileHeader:AddToListsButtonClicked')
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'list-add-remove-user',
|
||||||
|
subject: view.did,
|
||||||
|
displayName: view.displayName || view.handle,
|
||||||
|
})
|
||||||
|
}, [track, view, store])
|
||||||
|
|
||||||
const onPressMuteAccount = React.useCallback(async () => {
|
const onPressMuteAccount = React.useCallback(async () => {
|
||||||
track('ProfileHeader:MuteAccountButtonClicked')
|
track('ProfileHeader:MuteAccountButtonClicked')
|
||||||
try {
|
try {
|
||||||
|
@ -233,6 +244,11 @@ const ProfileHeaderLoaded = observer(
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onPress: onPressShare,
|
onPress: onPressShare,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
||||||
|
label: 'Add to Lists',
|
||||||
|
onPress: onPressAddRemoveLists,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
if (!isMe) {
|
if (!isMe) {
|
||||||
items.push({sep: true})
|
items.push({sep: true})
|
||||||
|
@ -269,6 +285,7 @@ const ProfileHeaderLoaded = observer(
|
||||||
onPressUnblockAccount,
|
onPressUnblockAccount,
|
||||||
onPressBlockAccount,
|
onPressBlockAccount,
|
||||||
onPressReportAccount,
|
onPressReportAccount,
|
||||||
|
onPressAddRemoveLists,
|
||||||
])
|
])
|
||||||
|
|
||||||
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
|
const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
|
||||||
|
@ -422,31 +439,42 @@ const ProfileHeaderLoaded = observer(
|
||||||
{view.viewer.blocking ? (
|
{view.viewer.blocking ? (
|
||||||
<View
|
<View
|
||||||
testID="profileHeaderBlockedNotice"
|
testID="profileHeaderBlockedNotice"
|
||||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
style={[styles.moderationNotice, pal.viewLight]}>
|
||||||
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
|
<FontAwesomeIcon icon="ban" style={[pal.text]} />
|
||||||
<Text type="md" style={[s.mr2, pal.text]}>
|
<Text type="lg-medium" style={pal.text}>
|
||||||
Account blocked
|
Account blocked
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : view.viewer.muted ? (
|
) : view.viewer.muted ? (
|
||||||
<View
|
<View
|
||||||
testID="profileHeaderMutedNotice"
|
testID="profileHeaderMutedNotice"
|
||||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
style={[styles.moderationNotice, pal.viewLight]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={['far', 'eye-slash']}
|
icon={['far', 'eye-slash']}
|
||||||
style={[pal.text, s.mr5]}
|
style={[pal.text]}
|
||||||
/>
|
/>
|
||||||
<Text type="md" style={[s.mr2, pal.text]}>
|
<Text type="lg-medium" style={pal.text}>
|
||||||
Account muted
|
Account muted{' '}
|
||||||
|
{view.viewer.mutedByList && (
|
||||||
|
<Text type="lg-medium" style={pal.text}>
|
||||||
|
by{' '}
|
||||||
|
<TextLink
|
||||||
|
type="lg-medium"
|
||||||
|
style={pal.link}
|
||||||
|
href={listUriToHref(view.viewer.mutedByList.uri)}
|
||||||
|
text={view.viewer.mutedByList.name}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{view.viewer.blockedBy && (
|
{view.viewer.blockedBy && (
|
||||||
<View
|
<View
|
||||||
testID="profileHeaderBlockedNotice"
|
testID="profileHeaderBlockedNotice"
|
||||||
style={[styles.moderationNotice, pal.view, pal.border]}>
|
style={[styles.moderationNotice, pal.viewLight]}>
|
||||||
<FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
|
<FontAwesomeIcon icon="ban" style={[pal.text]} />
|
||||||
<Text type="md" style={[s.mr2, pal.text]}>
|
<Text type="lg-medium" style={pal.text}>
|
||||||
This account has blocked you
|
This account has blocked you
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
@ -595,10 +623,10 @@ const styles = StyleSheet.create({
|
||||||
moderationNotice: {
|
moderationNotice: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 10,
|
paddingVertical: 14,
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
|
|
||||||
br40: {borderRadius: 40},
|
br40: {borderRadius: 40},
|
||||||
|
|
|
@ -10,17 +10,19 @@ import {UserGroupIcon} from 'lib/icons'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
|
testID,
|
||||||
icon,
|
icon,
|
||||||
message,
|
message,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
|
testID?: string
|
||||||
icon: IconProp | 'user-group'
|
icon: IconProp | 'user-group'
|
||||||
message: string
|
message: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View testID={testID} style={[styles.container, style]}>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
{icon === 'user-group' ? (
|
{icon === 'user-group' ? (
|
||||||
<UserGroupIcon size="64" style={styles.icon} />
|
<UserGroupIcon size="64" style={styles.icon} />
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||||
|
import {Text} from './text/Text'
|
||||||
|
import {Button} from './forms/Button'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
testID?: string
|
||||||
|
icon: IconProp
|
||||||
|
message: string
|
||||||
|
buttonLabel: string
|
||||||
|
onPress: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyStateWithButton(props: Props) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View testID={props.testID} style={styles.container}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={props.icon}
|
||||||
|
style={[styles.icon, pal.text]}
|
||||||
|
size={62}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
|
||||||
|
{props.message}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.btns}>
|
||||||
|
<Button
|
||||||
|
testID={props.testID ? `${props.testID}-button` : undefined}
|
||||||
|
type="inverted"
|
||||||
|
style={styles.btn}
|
||||||
|
onPress={props.onPress}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="plus"
|
||||||
|
style={palInverted.text as FontAwesomeIconStyle}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
<Text type="lg-medium" style={palInverted.text}>
|
||||||
|
{props.buttonLabel}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
height: '100%',
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
|
btns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
gap: 10,
|
||||||
|
marginVertical: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 30,
|
||||||
|
},
|
||||||
|
notice: {
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginHorizontal: 30,
|
||||||
|
},
|
||||||
|
})
|
|
@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({
|
||||||
canGoBack,
|
canGoBack,
|
||||||
hideOnScroll,
|
hideOnScroll,
|
||||||
showOnDesktop,
|
showOnDesktop,
|
||||||
|
renderButton,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
canGoBack?: boolean
|
canGoBack?: boolean
|
||||||
hideOnScroll?: boolean
|
hideOnScroll?: boolean
|
||||||
showOnDesktop?: boolean
|
showOnDesktop?: boolean
|
||||||
|
renderButton?: () => JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({
|
||||||
|
|
||||||
if (isDesktopWeb) {
|
if (isDesktopWeb) {
|
||||||
if (showOnDesktop) {
|
if (showOnDesktop) {
|
||||||
return <DesktopWebHeader title={title} />
|
return <DesktopWebHeader title={title} renderButton={renderButton} />
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
|
@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{renderButton ? (
|
||||||
|
renderButton()
|
||||||
|
) : (
|
||||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function DesktopWebHeader({title}: {title: string}) {
|
function DesktopWebHeader({
|
||||||
|
title,
|
||||||
|
renderButton,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
renderButton?: () => JSX.Element
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
|
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
|
||||||
|
@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) {
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{renderButton?.()}
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
|
||||||
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
|
import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
|
||||||
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
|
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
|
||||||
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
|
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
|
||||||
|
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
|
||||||
|
import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
|
||||||
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
|
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
|
||||||
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
|
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
|
||||||
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
|
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
|
||||||
|
@ -46,6 +48,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
|
||||||
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
|
import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
|
||||||
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
|
import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
|
||||||
import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
|
import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
|
||||||
|
import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
|
||||||
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
||||||
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
||||||
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
|
import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
|
||||||
|
@ -67,8 +70,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
|
||||||
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
|
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
|
||||||
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
|
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
|
||||||
import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
|
import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
|
||||||
|
import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
|
||||||
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
|
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
|
||||||
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
|
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
|
||||||
|
import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
||||||
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
||||||
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
|
||||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||||
|
@ -116,6 +121,8 @@ export function setup() {
|
||||||
farEyeSlash,
|
farEyeSlash,
|
||||||
faGear,
|
faGear,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
|
faHand,
|
||||||
|
farHand,
|
||||||
faHeart,
|
faHeart,
|
||||||
fasHeart,
|
fasHeart,
|
||||||
faHouse,
|
faHouse,
|
||||||
|
@ -124,6 +131,7 @@ export function setup() {
|
||||||
faInfo,
|
faInfo,
|
||||||
faLanguage,
|
faLanguage,
|
||||||
faLink,
|
faLink,
|
||||||
|
faListUl,
|
||||||
faLock,
|
faLock,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faMessage,
|
faMessage,
|
||||||
|
@ -145,8 +153,10 @@ export function setup() {
|
||||||
faUser,
|
faUser,
|
||||||
faUsers,
|
faUsers,
|
||||||
faUserCheck,
|
faUserCheck,
|
||||||
|
faUserSlash,
|
||||||
faUserPlus,
|
faUserPlus,
|
||||||
faUserXmark,
|
faUserXmark,
|
||||||
|
faUsersSlash,
|
||||||
faTicket,
|
faTicket,
|
||||||
faTrashCan,
|
faTrashCan,
|
||||||
faX,
|
faX,
|
||||||
|
|
|
@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
|
||||||
setSelectedPage(index)
|
setSelectedPage(index)
|
||||||
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
||||||
},
|
},
|
||||||
[store],
|
[store, setSelectedPage],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSelected = React.useCallback(() => {
|
const onPressSelected = React.useCallback(() => {
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
|
import {CenteredView} from '../com/util/Views'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
import {Link} from '../com/util/Link'
|
||||||
|
import {Text} from '../com/util/text/Text'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||||
|
export const ModerationScreen = withAuthRequired(
|
||||||
|
observer(function Moderation({}: Props) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {screen, track} = useAnalytics()
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
screen('Moderation')
|
||||||
|
store.shell.setMinimalShellMode(false)
|
||||||
|
}, [screen, store]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressContentFiltering = React.useCallback(() => {
|
||||||
|
track('Moderation:ContentfilteringButtonClicked')
|
||||||
|
store.shell.openModal({name: 'content-filtering-settings'})
|
||||||
|
}, [track, store])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
style={[
|
||||||
|
s.hContentRegion,
|
||||||
|
pal.border,
|
||||||
|
isDesktopWeb ? styles.desktopContainer : pal.viewLight,
|
||||||
|
]}
|
||||||
|
testID="moderationScreen">
|
||||||
|
<ViewHeader title="Moderation" showOnDesktop />
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="contentFilteringBtn"
|
||||||
|
style={[styles.linkCard, pal.view]}
|
||||||
|
onPress={onPressContentFiltering}
|
||||||
|
accessibilityHint="Content filtering"
|
||||||
|
accessibilityLabel="Opens configurable content filtering settings">
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="eye"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Content filtering
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Link
|
||||||
|
testID="mutelistsBtn"
|
||||||
|
style={[styles.linkCard, pal.view]}
|
||||||
|
href="/moderation/mute-lists">
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="users-slash"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Mute lists
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
testID="mutedAccountsBtn"
|
||||||
|
style={[styles.linkCard, pal.view]}
|
||||||
|
href="/moderation/muted-accounts">
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="user-slash"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Muted accounts
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
testID="blockedAccountsBtn"
|
||||||
|
style={[styles.linkCard, pal.view]}
|
||||||
|
href="/moderation/blocked-accounts">
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="ban"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
Blocked accounts
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
desktopContainer: {
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: 6,
|
||||||
|
},
|
||||||
|
linkCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 30,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
})
|
|
@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {CenteredView} from 'view/com/util/Views'
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'>
|
type Props = NativeStackScreenProps<
|
||||||
export const BlockedAccounts = withAuthRequired(
|
CommonNavigatorParams,
|
||||||
|
'ModerationBlockedAccounts'
|
||||||
|
>
|
||||||
|
export const ModerationBlockedAccounts = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(({}: Props) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet} from 'react-native'
|
||||||
|
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconStyle,
|
||||||
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
|
import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {ListsListModel} from 'state/models/lists/lists-list'
|
||||||
|
import {ListsList} from 'view/com/lists/ListsList'
|
||||||
|
import {Button} from 'view/com/util/forms/Button'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<
|
||||||
|
CommonNavigatorParams,
|
||||||
|
'ModerationMuteLists'
|
||||||
|
>
|
||||||
|
export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const mutelists: ListsListModel = React.useMemo(
|
||||||
|
() => new ListsListModel(store, 'my-modlists'),
|
||||||
|
[store],
|
||||||
|
)
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
store.shell.setMinimalShellMode(false)
|
||||||
|
mutelists.refresh()
|
||||||
|
}, [store, mutelists]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressNewMuteList = React.useCallback(() => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'create-or-edit-mute-list',
|
||||||
|
onSave: (uri: string) => {
|
||||||
|
try {
|
||||||
|
const urip = new AtUri(uri)
|
||||||
|
navigation.navigate('ProfileList', {
|
||||||
|
name: urip.hostname,
|
||||||
|
rkey: urip.rkey,
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [store, navigation])
|
||||||
|
|
||||||
|
const renderEmptyState = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<EmptyStateWithButton
|
||||||
|
testID="emptyMuteLists"
|
||||||
|
icon="users-slash"
|
||||||
|
message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
|
||||||
|
buttonLabel="New Mute List"
|
||||||
|
onPress={onPressNewMuteList}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}, [onPressNewMuteList])
|
||||||
|
|
||||||
|
const renderHeaderButton = React.useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
type="primary-light"
|
||||||
|
onPress={onPressNewMuteList}
|
||||||
|
style={styles.createBtn}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="plus"
|
||||||
|
style={pal.link as FontAwesomeIconStyle}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[onPressNewMuteList, pal],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
isDesktopWeb && styles.containerDesktop,
|
||||||
|
pal.view,
|
||||||
|
pal.border,
|
||||||
|
]}
|
||||||
|
testID="moderationMutelistsScreen">
|
||||||
|
<ViewHeader
|
||||||
|
title="Mute Lists"
|
||||||
|
showOnDesktop
|
||||||
|
renderButton={renderHeaderButton}
|
||||||
|
/>
|
||||||
|
<ListsList
|
||||||
|
listsList={mutelists}
|
||||||
|
showAddBtns={isDesktopWeb}
|
||||||
|
renderEmptyState={renderEmptyState}
|
||||||
|
onPressCreateNew={onPressNewMuteList}
|
||||||
|
/>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isDesktopWeb ? 0 : 100,
|
||||||
|
},
|
||||||
|
containerDesktop: {
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
},
|
||||||
|
createBtn: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
})
|
|
@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {CenteredView} from 'view/com/util/Views'
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MutedAccounts'>
|
type Props = NativeStackScreenProps<
|
||||||
export const MutedAccounts = withAuthRequired(
|
CommonNavigatorParams,
|
||||||
|
'ModerationMutedAccounts'
|
||||||
|
>
|
||||||
|
export const ModerationMutedAccounts = withAuthRequired(
|
||||||
observer(({}: Props) => {
|
observer(({}: Props) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
|
@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
import {ViewSelector} from '../com/util/ViewSelector'
|
import {ViewSelector} from '../com/util/ViewSelector'
|
||||||
import {CenteredView} from '../com/util/Views'
|
import {CenteredView} from '../com/util/Views'
|
||||||
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
|
||||||
import {ProfileUiModel} from 'state/models/ui/profile'
|
import {ProfileUiModel, Sections} from 'state/models/ui/profile'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
import {PostsFeedSliceModel} from 'state/models/feeds/posts'
|
||||||
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
import {ProfileHeader} from '../com/profile/ProfileHeader'
|
||||||
import {FeedSlice} from '../com/posts/FeedSlice'
|
import {FeedSlice} from '../com/posts/FeedSlice'
|
||||||
import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
|
import {ListCard} from 'view/com/lists/ListCard'
|
||||||
|
import {
|
||||||
|
PostFeedLoadingPlaceholder,
|
||||||
|
ProfileCardFeedLoadingPlaceholder,
|
||||||
|
} from '../com/util/LoadingPlaceholder'
|
||||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||||
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
||||||
import {EmptyState} from '../com/util/EmptyState'
|
import {EmptyState} from '../com/util/EmptyState'
|
||||||
|
@ -111,6 +115,31 @@ export const ProfileScreen = withAuthRequired(
|
||||||
}, [uiState.showLoadingMoreFooter])
|
}, [uiState.showLoadingMoreFooter])
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
|
if (uiState.selectedView === Sections.Lists) {
|
||||||
|
if (item === ProfileUiModel.LOADING_ITEM) {
|
||||||
|
return <ProfileCardFeedLoadingPlaceholder />
|
||||||
|
} else if (item._reactKey === '__error__') {
|
||||||
|
return (
|
||||||
|
<View style={s.p5}>
|
||||||
|
<ErrorMessage
|
||||||
|
message={item.error}
|
||||||
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
} else if (item === ProfileUiModel.EMPTY_ITEM) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
testID="listsEmpty"
|
||||||
|
icon="list-ul"
|
||||||
|
message="No lists yet!"
|
||||||
|
style={styles.emptyState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return <ListCard testID={`list-${item.name}`} list={item} />
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (item === ProfileUiModel.END_ITEM) {
|
if (item === ProfileUiModel.END_ITEM) {
|
||||||
return <Text style={styles.endItem}>- end of feed -</Text>
|
return <Text style={styles.endItem}>- end of feed -</Text>
|
||||||
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
} else if (item === ProfileUiModel.LOADING_ITEM) {
|
||||||
|
@ -151,12 +180,16 @@ export const ProfileScreen = withAuthRequired(
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (item instanceof PostsFeedSliceModel) {
|
} else if (item instanceof PostsFeedSliceModel) {
|
||||||
return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
return (
|
||||||
|
<FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return <View />
|
return <View />
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
|
uiState.selectedView,
|
||||||
uiState.profile.did,
|
uiState.profile.did,
|
||||||
uiState.feed.isBlocking,
|
uiState.feed.isBlocking,
|
||||||
uiState.feed.isBlockedBy,
|
uiState.feed.isBlockedBy,
|
||||||
|
|
|
@ -0,0 +1,175 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, View} from 'react-native'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
|
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||||
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
|
import {ListItems} from 'view/com/lists/ListItems'
|
||||||
|
import {EmptyState} from 'view/com/util/EmptyState'
|
||||||
|
import {Button} from 'view/com/util/forms/Button'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
import {ListModel} from 'state/models/content/list'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
|
||||||
|
export const ProfileListScreen = withAuthRequired(
|
||||||
|
observer(({route}: Props) => {
|
||||||
|
const store = useStores()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const {name, rkey} = route.params
|
||||||
|
|
||||||
|
const list: ListModel = React.useMemo(() => {
|
||||||
|
const model = new ListModel(
|
||||||
|
store,
|
||||||
|
`at://${name}/app.bsky.graph.list/${rkey}`,
|
||||||
|
)
|
||||||
|
return model
|
||||||
|
}, [store, name, rkey])
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
React.useCallback(() => {
|
||||||
|
store.shell.setMinimalShellMode(false)
|
||||||
|
list.loadMore(true)
|
||||||
|
}, [store, list]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onToggleSubscribed = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (list.list?.viewer?.muted) {
|
||||||
|
await list.unsubscribe()
|
||||||
|
} else {
|
||||||
|
await list.subscribe()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show(
|
||||||
|
'There was an an issue updating your subscription, please check your internet connection and try again.',
|
||||||
|
)
|
||||||
|
store.log.error('Failed up update subscription', {err})
|
||||||
|
}
|
||||||
|
}, [store, list])
|
||||||
|
|
||||||
|
const onPressEditList = React.useCallback(() => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'create-or-edit-mute-list',
|
||||||
|
list,
|
||||||
|
onSave() {
|
||||||
|
list.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [store, list])
|
||||||
|
|
||||||
|
const onPressDeleteList = React.useCallback(() => {
|
||||||
|
store.shell.openModal({
|
||||||
|
name: 'confirm',
|
||||||
|
title: 'Delete List',
|
||||||
|
message: 'Are you sure?',
|
||||||
|
async onPressConfirm() {
|
||||||
|
await list.delete()
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [store, list, navigation])
|
||||||
|
|
||||||
|
const renderEmptyState = React.useCallback(() => {
|
||||||
|
return <EmptyState icon="users-slash" message="This list is empty!" />
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderHeaderBtn = React.useCallback(() => {
|
||||||
|
return (
|
||||||
|
<View style={styles.headerBtns}>
|
||||||
|
{list?.isOwner && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
label="Delete List"
|
||||||
|
testID="deleteListBtn"
|
||||||
|
accessibilityLabel="Delete list"
|
||||||
|
accessibilityHint="Deletes the mutelist"
|
||||||
|
onPress={onPressDeleteList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{list?.isOwner && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
label="Edit List"
|
||||||
|
testID="editListBtn"
|
||||||
|
accessibilityLabel="Edit list"
|
||||||
|
accessibilityHint="Opens a modal to edit the mutelist"
|
||||||
|
onPress={onPressEditList}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{list.list?.viewer?.muted ? (
|
||||||
|
<Button
|
||||||
|
type="inverted"
|
||||||
|
label="Unsubscribe"
|
||||||
|
testID="unsubscribeListBtn"
|
||||||
|
accessibilityLabel="Unsubscribe from this list"
|
||||||
|
accessibilityHint="Stops muting the users included in this list"
|
||||||
|
onPress={onToggleSubscribed}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
label="Subscribe & Mute"
|
||||||
|
testID="subscribeListBtn"
|
||||||
|
accessibilityLabel="Subscribe to this list"
|
||||||
|
accessibilityHint="Mutes the users included in this list"
|
||||||
|
onPress={onToggleSubscribed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
list?.isOwner,
|
||||||
|
list.list?.viewer?.muted,
|
||||||
|
onPressDeleteList,
|
||||||
|
onPressEditList,
|
||||||
|
onToggleSubscribed,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenteredView
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
isDesktopWeb && styles.containerDesktop,
|
||||||
|
pal.view,
|
||||||
|
pal.border,
|
||||||
|
]}
|
||||||
|
testID="moderationMutelistsScreen">
|
||||||
|
<ViewHeader title="" renderButton={renderHeaderBtn} />
|
||||||
|
<ListItems
|
||||||
|
list={list}
|
||||||
|
renderEmptyState={renderEmptyState}
|
||||||
|
onToggleSubscribed={onToggleSubscribed}
|
||||||
|
onPressEditList={onPressEditList}
|
||||||
|
onPressDeleteList={onPressDeleteList}
|
||||||
|
/>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
headerBtns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingBottom: isDesktopWeb ? 0 : 100,
|
||||||
|
},
|
||||||
|
containerDesktop: {
|
||||||
|
borderLeftWidth: 1,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
},
|
||||||
|
})
|
|
@ -127,11 +127,6 @@ export const SettingsScreen = withAuthRequired(
|
||||||
store.shell.openModal({name: 'invite-codes'})
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
}, [track, store])
|
}, [track, store])
|
||||||
|
|
||||||
const onPressContentFiltering = React.useCallback(() => {
|
|
||||||
track('Settings:ContentfilteringButtonClicked')
|
|
||||||
store.shell.openModal({name: 'content-filtering-settings'})
|
|
||||||
}, [track, store])
|
|
||||||
|
|
||||||
const onPressContentLanguages = React.useCallback(() => {
|
const onPressContentLanguages = React.useCallback(() => {
|
||||||
track('Settings:ContentlanguagesButtonClicked')
|
track('Settings:ContentlanguagesButtonClicked')
|
||||||
store.shell.openModal({name: 'content-languages-settings'})
|
store.shell.openModal({name: 'content-languages-settings'})
|
||||||
|
@ -252,7 +247,9 @@ export const SettingsScreen = withAuthRequired(
|
||||||
Add account
|
Add account
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
Invite a friend
|
Invite a friend
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -287,54 +284,6 @@ export const SettingsScreen = withAuthRequired(
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
|
||||||
Moderation
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="contentFilteringBtn"
|
|
||||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
|
||||||
onPress={isSwitching ? undefined : onPressContentFiltering}
|
|
||||||
accessibilityHint="Content moderation"
|
|
||||||
accessibilityLabel="Opens configurable content moderation settings">
|
|
||||||
<View style={[styles.iconContainer, pal.btn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="eye"
|
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
Content moderation
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Link
|
|
||||||
testID="mutedAccountsBtn"
|
|
||||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
|
||||||
href="/settings/muted-accounts">
|
|
||||||
<View style={[styles.iconContainer, pal.btn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'eye-slash']}
|
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
Muted accounts
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
testID="blockedAccountsBtn"
|
|
||||||
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
|
||||||
href="/settings/blocked-accounts">
|
|
||||||
<View style={[styles.iconContainer, pal.btn]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="ban"
|
|
||||||
style={pal.text as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text type="lg" style={pal.text}>
|
|
||||||
Blocked accounts
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
<View style={styles.spacer20} />
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
Advanced
|
Advanced
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -94,6 +94,12 @@ export const DrawerContent = observer(() => {
|
||||||
onPressTab('MyProfile')
|
onPressTab('MyProfile')
|
||||||
}, [onPressTab])
|
}, [onPressTab])
|
||||||
|
|
||||||
|
const onPressModeration = React.useCallback(() => {
|
||||||
|
track('Menu:ItemClicked', {url: 'Moderation'})
|
||||||
|
navigation.navigate('Moderation')
|
||||||
|
store.shell.closeDrawer()
|
||||||
|
}, [navigation, track, store.shell])
|
||||||
|
|
||||||
const onPressSettings = React.useCallback(() => {
|
const onPressSettings = React.useCallback(() => {
|
||||||
track('Menu:ItemClicked', {url: 'Settings'})
|
track('Menu:ItemClicked', {url: 'Settings'})
|
||||||
navigation.navigate('Settings')
|
navigation.navigate('Settings')
|
||||||
|
@ -220,6 +226,19 @@ export const DrawerContent = observer(() => {
|
||||||
bold={isAtNotifications}
|
bold={isAtNotifications}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
/>
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={['far', 'hand']}
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Moderation"
|
||||||
|
accessibilityLabel="Moderation"
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onPressModeration}
|
||||||
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={
|
icon={
|
||||||
isAtMyProfile ? (
|
isAtMyProfile ? (
|
||||||
|
|
|
@ -203,6 +203,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
||||||
}
|
}
|
||||||
label="Notifications"
|
label="Notifications"
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/moderation"
|
||||||
|
icon={
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={['far', 'hand']}
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
iconFilled={
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="hand"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Moderation"
|
||||||
|
/>
|
||||||
{store.session.hasSession && (
|
{store.session.hasSession && (
|
||||||
<NavItem
|
<NavItem
|
||||||
href={`/profile/${store.me.handle}`}
|
href={`/profile/${store.me.handle}`}
|
||||||
|
|
21
yarn.lock
21
yarn.lock
|
@ -29,7 +29,7 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@*", "@atproto/api@0.2.11":
|
"@atproto/api@*":
|
||||||
version "0.2.11"
|
version "0.2.11"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf"
|
||||||
integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ==
|
integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ==
|
||||||
|
@ -40,6 +40,17 @@
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
|
|
||||||
|
"@atproto/api@0.3.1":
|
||||||
|
version "0.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.1.tgz#98699479f8385c7494a853144657895be392c3f3"
|
||||||
|
integrity sha512-/AAZntrLUPCxw7q8FMtDsSYOjsAs5aAmllmArXyye5ITvbSw4pzWfJcBiKnQdmXpdwSrVWVEX7uwIp+GYWopqg==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "*"
|
||||||
|
"@atproto/uri" "*"
|
||||||
|
"@atproto/xrpc" "*"
|
||||||
|
tlds "^1.234.0"
|
||||||
|
typed-emitter "^2.1.0"
|
||||||
|
|
||||||
"@atproto/common-web@*":
|
"@atproto/common-web@*":
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.1.0.tgz#5529fa66f9533aa00cfd13f0a25757df7b26bd3d"
|
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.1.0.tgz#5529fa66f9533aa00cfd13f0a25757df7b26bd3d"
|
||||||
|
@ -137,10 +148,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
|
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
|
||||||
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
|
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
|
||||||
|
|
||||||
"@atproto/pds@^0.1.5":
|
"@atproto/pds@^0.1.6":
|
||||||
version "0.1.5"
|
version "0.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.5.tgz#59411497f2d85b6706ab793e8f7f618bdb8c51a3"
|
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.6.tgz#2858355887eac06f5e2da8701231e5cb46004c18"
|
||||||
integrity sha512-QtTf2mbqO5MEsrXPTFU43dSb0WT3TzaLw5mL++9w18CZDMvdmv2uJXKeaSiU+u3WJEtRpRs5hoLSdfrJ2i3PuA==
|
integrity sha512-bddIWH+OrEIxJ5HYst1mBS+95bNWC08FLaa3DVtJRHRCdfYaGDndZUVpOLLgzBRklDLicJyvva2JYEgp2mdgLA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/api" "*"
|
"@atproto/api" "*"
|
||||||
"@atproto/common" "*"
|
"@atproto/common" "*"
|
||||||
|
|
Loading…
Reference in New Issue