refs #2258 Apply filter to home timeline

This commit is contained in:
AkiraFukushima 2021-05-22 01:43:27 +09:00
parent c9d860465c
commit 23f2c95ef9
No known key found for this signature in database
GPG Key ID: B6E51BAC4DE1A957
8 changed files with 193 additions and 19 deletions

View File

@ -84,7 +84,8 @@ const state = (): TimelineSpaceState => {
local: true,
public: true
},
sns: 'mastodon'
sns: 'mastodon',
filters: []
}
}

View File

@ -16,7 +16,8 @@ describe('TimelineSpace', () => {
local: unreadSettings.Local.default,
public: unreadSettings.Public.default
},
sns: 'mastodon'
sns: 'mastodon',
filters: []
}
})

View File

@ -0,0 +1,106 @@
import filtered from '@/utils/filter'
import { Entity } from 'megalodon'
describe('filter', () => {
describe('whole word is enabled', () => {
describe('Only asci', () => {
const filters = [
{
id: '1',
phrase: 'Fedi',
context: ['home'],
expires_at: null,
irreversible: false,
whole_word: true
} as Entity.Filter
]
it('should not be matched', () => {
const status =
'Pleroma is social networking software compatible with other Fediverse software such as Mastodon, Misskey, Pixelfed and many others.'
const res = filtered(status, filters)
expect(res).toBeFalsy()
})
it('should be matched', () => {
const status =
'Pleroma is social networking software compatible with other Fedi software such as Mastodon, Misskey, Pixelfed and many others.'
const res = filtered(status, filters)
expect(res).toBeTruthy()
})
})
describe('With Japanese', () => {
const filters = [
{
id: '1',
phrase: 'ミニブログ',
context: ['home'],
expires_at: null,
irreversible: false,
whole_word: true
} as Entity.Filter
]
it('should be matched', () => {
const status =
'マストドン (Mastodon) はミニブログサービスを提供するためのフリーソフトウェア、またはこれが提供する連合型のソーシャルネットワークサービスである'
const res = filtered(status, filters)
expect(res).toBeTruthy()
})
it('should not be matched', () => {
const status =
'「脱中央集権型」 (decentralized) のマストドンのサーバーはだれでも自由に運用する事が可能であり、利用者は通常このサーバーの一つを選んで所属するが、異なるサーバーに属する利用者間のコミュニケーションも容易である'
const res = filtered(status, filters)
expect(res).toBeFalsy()
})
})
})
describe('whole word is disabled', () => {
describe('Only asci', () => {
const filters = [
{
id: '1',
phrase: 'Fedi',
context: ['home'],
expires_at: null,
irreversible: false,
whole_word: false
} as Entity.Filter
]
it('should be matched', () => {
const status =
'Pleroma is social networking software compatible with other Fediverse software such as Mastodon, Misskey, Pixelfed and many others.'
const res = filtered(status, filters)
expect(res).toBeTruthy()
})
it('should be matched', () => {
const status =
'Pleroma is social networking software compatible with other Fedi software such as Mastodon, Misskey, Pixelfed and many others.'
const res = filtered(status, filters)
expect(res).toBeTruthy()
})
})
describe('With Japanese', () => {
const filters = [
{
id: '1',
phrase: 'ミニブログ',
context: ['home'],
expires_at: null,
irreversible: false,
whole_word: true
} as Entity.Filter
]
it('should be matched', () => {
const status =
'マストドン (Mastodon) はミニブログサービスを提供するためのフリーソフトウェア、またはこれが提供する連合型のソーシャルネットワークサービスである'
const res = filtered(status, filters)
expect(res).toBeTruthy()
})
it('should not be matched', () => {
const status =
'「脱中央集権型」 (decentralized) のマストドンのサーバーはだれでも自由に運用する事が可能であり、利用者は通常このサーバーの一つを選んで所属するが、異なるサーバーに属する利用者間のコミュニケーションも容易である'
const res = filtered(status, filters)
expect(res).toBeFalsy()
})
})
})
})

View File

@ -9,6 +9,7 @@
:message="item"
:focused="item.uri + item.id === focusedId"
:overlaid="modalOpened"
:filters="filters"
v-on:update="updateToot"
v-on:delete="deleteToot"
@focusNext="focusNext"
@ -44,18 +45,21 @@ export default {
}
},
computed: {
...mapState('TimelineSpace/Contents/Home', {
timeline: state => state.timeline,
lazyLoading: state => state.lazyLoading,
heading: state => state.heading,
unread: state => state.unreadTimeline,
showReblogs: state => state.showReblogs,
showReplies: state => state.showReplies
}),
...mapState({
openSideBar: state => state.TimelineSpace.Contents.SideBar.openSideBar,
backgroundColor: state => state.App.theme.background_color,
startReload: state => state.TimelineSpace.HeaderMenu.reload,
timeline: state => state.TimelineSpace.Contents.Home.timeline,
lazyLoading: state => state.TimelineSpace.Contents.Home.lazyLoading,
heading: state => state.TimelineSpace.Contents.Home.heading,
unread: state => state.TimelineSpace.Contents.Home.unreadTimeline,
showReblogs: state => state.TimelineSpace.Contents.Home.showReblogs,
showReplies: state => state.TimelineSpace.Contents.Home.showReplies
openSideBar: state => state.TimelineSpace.Contents.SideBar.openSideBar,
startReload: state => state.TimelineSpace.HeaderMenu.reload
}),
...mapGetters('TimelineSpace/Modals', ['modalOpened']),
...mapGetters('TimelineSpace/Contents/Home', ['filters']),
shortcutEnabled: function () {
if (this.modalOpened) {
return false

View File

@ -269,6 +269,7 @@ import LinkPreview from '~/src/renderer/components/molecules/Toot/LinkPreview'
import Quote from '@/components/molecules/Toot/Quote'
import { setInterval, clearInterval } from 'timers'
import QuoteSupported from '@/utils/quoteSupported'
import Filtered from '@/utils/filter'
export default {
name: 'toot',
@ -297,9 +298,9 @@ export default {
type: Object,
default: {}
},
filter: {
type: String,
default: ''
filters: {
type: Array,
default: []
},
focused: {
type: Boolean,
@ -389,7 +390,7 @@ export default {
return !this.sensitive || this.showAttachments
},
filtered: function () {
return this.filter.length > 0 && this.originalMessage.content.search(this.filter) >= 0
return Filtered(this.originalMessage.content, this.filters)
},
locked: function () {
return this.message.visibility === 'private'

View File

@ -22,6 +22,7 @@ export type TimelineSpaceState = {
tootMax: number
unreadNotification: UnreadNotification
sns: 'mastodon' | 'pleroma' | 'misskey'
filters: Array<Entity.Filter>
}
export const blankAccount: LocalAccount = {
@ -49,7 +50,8 @@ const state = (): TimelineSpaceState => ({
local: unreadSettings.Local.default,
public: unreadSettings.Public.default
},
sns: 'mastodon'
sns: 'mastodon',
filters: []
})
export const MUTATION_TYPES = {
@ -59,7 +61,8 @@ export const MUTATION_TYPES = {
UPDATE_EMOJIS: 'updateEmojis',
UPDATE_TOOT_MAX: 'updateTootMax',
UPDATE_UNREAD_NOTIFICATION: 'updateUnreadNotification',
CHANGE_SNS: 'changeSNS'
CHANGE_SNS: 'changeSNS',
UPDATE_FILTERS: 'updateFilters'
}
const mutations: MutationTree<TimelineSpaceState> = {
@ -87,6 +90,9 @@ const mutations: MutationTree<TimelineSpaceState> = {
},
[MUTATION_TYPES.CHANGE_SNS]: (state, sns: 'mastodon' | 'pleroma' | 'misskey') => {
state.sns = sns
},
[MUTATION_TYPES.UPDATE_FILTERS]: (state, filters: Array<Entity.Filter>) => {
state.filters = filters
}
}
@ -104,6 +110,7 @@ const actions: ActionTree<TimelineSpaceState, RootState> = {
dispatch('TimelineSpace/SideMenu/fetchFollowRequests', account, { root: true })
dispatch('TimelineSpace/SideMenu/confirmTimelines', account, { root: true })
await dispatch('loadUnreadNotification', accountId)
await dispatch('fetchFilters')
commit(MUTATION_TYPES.CHANGE_LOADING, false)
await dispatch('fetchContentsTimelines').catch(_ => {
throw new TimelineFetchError()
@ -175,6 +182,15 @@ const actions: ActionTree<TimelineSpaceState, RootState> = {
commit(MUTATION_TYPES.UPDATE_EMOJIS, res.data)
return res.data
},
/**
* fetchFilters
*/
fetchFilters: async ({ commit, state, rootState }): Promise<Array<Entity.Filter>> => {
const client = generator(state.sns, state.account.baseURL, state.account.accessToken, rootState.App.userAgent)
const res = await client.getFilters()
commit(MUTATION_TYPES.UPDATE_FILTERS, res.data)
return res.data
},
/**
* fetchInstance
*/

View File

@ -1,5 +1,5 @@
import generator, { Entity } from 'megalodon'
import { Module, MutationTree, ActionTree } from 'vuex'
import generator, { Entity, FilterContext } from 'megalodon'
import { Module, MutationTree, ActionTree, GetterTree } from 'vuex'
import { RootState } from '@/store'
export type HomeState = {
@ -138,11 +138,18 @@ const actions: ActionTree<HomeState, RootState> = {
}
}
const getters: GetterTree<HomeState, RootState> = {
filters: (_state, _getters, rootState) => {
return rootState.TimelineSpace.filters.filter(f => f.context.includes(FilterContext.Home) && !f.irreversible)
}
}
const Home: Module<HomeState, RootState> = {
namespaced: true,
state: state,
mutations: mutations,
actions: actions
actions: actions,
getters: getters
}
export default Home

View File

@ -0,0 +1,38 @@
import { Entity } from 'megalodon'
// refs: https://github.com/tootsuite/mastodon/blob/c3aef491d66aec743a3a53e934a494f653745b61/app/javascript/mastodon/selectors/index.js#L43
const filtered = (status: string, filters: Array<Entity.Filter>): boolean => {
if (filters.length === 0) {
return false
}
const regexp = filterRegexp(filters)
return status.match(regexp) !== null
}
const filterRegexp = (filters: Array<Entity.Filter>): RegExp => {
return new RegExp(
filters
.map(f => {
let exp = escapeRegExp(f.phrase)
if (f.whole_word) {
if (/^[\w]/.test(exp)) {
exp = `\\b${exp}`
}
if (/[\w]$/.test(exp)) {
exp = `${exp}\\b`
}
}
return exp
})
.join('|'),
'i'
)
}
const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export default filtered