diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/__tests__/moderatePost_wrapped.test.ts | 692 | ||||
-rw-r--r-- | src/lib/api/index.ts | 6 | ||||
-rw-r--r-- | src/lib/constants.ts | 6 | ||||
-rw-r--r-- | src/lib/hooks/useIntentHandler.ts | 91 | ||||
-rw-r--r-- | src/lib/moderatePost_wrapped.ts | 205 | ||||
-rw-r--r-- | src/lib/moderation.ts | 7 | ||||
-rw-r--r-- | src/lib/routes/links.ts | 10 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/lib/strings/helpers.ts | 21 |
9 files changed, 1033 insertions, 6 deletions
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts new file mode 100644 index 000000000..45566281a --- /dev/null +++ b/src/lib/__tests__/moderatePost_wrapped.test.ts @@ -0,0 +1,692 @@ +import {describe, it, expect} from '@jest/globals' +import {RichText} from '@atproto/api' + +import {hasMutedWord} from '../moderatePost_wrapped' + +describe(`hasMutedWord`, () => { + describe(`tags`, () => { + it(`match: outline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'outlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: content target matches inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`no match: only tag targets`, () => { + const rt = new RichText({ + text: `This is a post`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + }) + + describe(`early exits`, () => { + it(`match: single character 希`, () => { + /** + * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c + */ + const rt = new RichText({ + text: `改善希望です`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: '希', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`no match: long muted word, short post`, () => { + const rt = new RichText({ + text: `hey`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'politics', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + + it(`match: exact text`, () => { + const rt = new RichText({ + text: `javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'javascript', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`general content`, () => { + it(`match: word within post`, () => { + const rt = new RichText({ + text: `This is a post about javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'javascript', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`no match: partial word`, () => { + const rt = new RichText({ + text: `Use your brain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'ai', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + + it(`match: multiline`, () => { + const rt = new RichText({ + text: `Use your\n\tbrain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: 'brain', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: :)`, () => { + const rt = new RichText({ + text: `So happy :)`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: `:)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`punctuation semi-fuzzy`, () => { + describe(`yay!`, () => { + const rt = new RichText({ + text: `We're federating, yay!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: yay!`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'yay!', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: yay`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'yay', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`y!ppee!!`, () => { + const rt = new RichText({ + text: `We're federating, y!ppee!!`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: y!ppee`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'y!ppee', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + // single exclamation point, source has double + it(`no match: y!ppee!`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'y!ppee!', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`Why so S@assy?`, () => { + const rt = new RichText({ + text: `Why so S@assy?`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: S@assy`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'S@assy', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: s@assy`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 's@assy', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`New York Times`, () => { + const rt = new RichText({ + text: `New York Times`, + }) + rt.detectFacetsWithoutResolution() + + // case insensitive + it(`match: new york times`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'new york times', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`!command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot !command`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: !command`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `!command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: command`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`no match: !command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot command`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord({ + mutedWords: [{value: `!command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + }) + + describe(`e/acc`, () => { + const rt = new RichText({ + text: `I'm e/acc pilled`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: e/acc`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `e/acc`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: acc`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `acc`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`super-bad`, () => { + const rt = new RichText({ + text: `I'm super-bad`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: super-bad`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `super-bad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: super`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `super`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: super bad`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `super bad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: superbad`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `superbad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + }) + + describe(`idk_what_this_would_be`, () => { + const rt = new RichText({ + text: `Weird post with idk_what_this_would_be`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: idk what this would be`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `idk what this would be`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`no match: idk what this would be for`, () => { + // extra word + const match = hasMutedWord({ + mutedWords: [ + {value: `idk what this would be for`, targets: ['content']}, + ], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + + it(`match: idk`, () => { + // extra word + const match = hasMutedWord({ + mutedWords: [{value: `idk`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: idkwhatthiswouldbe`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(false) + }) + }) + + describe(`parentheses`, () => { + const rt = new RichText({ + text: `Post with context(iykyk)`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: context(iykyk)`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `context(iykyk)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: context`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `context`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: iykyk`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `iykyk`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: (iykyk)`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `(iykyk)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + + describe(`🦋`, () => { + const rt = new RichText({ + text: `Post with 🦋`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: 🦋`, () => { + const match = hasMutedWord({ + mutedWords: [{value: `🦋`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`phrases`, () => { + describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => { + const rt = new RichText({ + text: `I like turtles, or how I learned to stop worrying and love the internet.`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: stop worrying`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'stop worrying', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`match: turtles, or how`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'turtles, or how', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`languages without spaces`, () => { + // I love turtles, or how I learned to stop worrying and love the internet + describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => { + const rt = new RichText({ + text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, + }) + rt.detectFacetsWithoutResolution() + + // internet + it(`match: インターネット`, () => { + const match = hasMutedWord({ + mutedWords: [{value: 'インターネット', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + languages: ['ja'], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + }) + }) + + describe(`doesn't mute own post`, () => { + it(`does mute if it isn't own post`, () => { + const rt = new RichText({ + text: `Mute words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{value: 'words', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: false, + }) + + expect(match).toBe(true) + }) + + it(`doesn't mute own post when muted word is in text`, () => { + const rt = new RichText({ + text: `Mute words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{value: 'words', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: true, + }) + + expect(match).toBe(false) + }) + + it(`doesn't mute own post when muted word is in tags`, () => { + const rt = new RichText({ + text: `Mute #words!`, + }) + + const match = hasMutedWord({ + mutedWords: [{value: 'words', targets: ['tags']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + isOwnPost: true, + }) + + expect(match).toBe(false) + }) + }) +}) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 440dfa5ee..5fb7fe50e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) { // add image embed if present if (opts.images?.length) { - logger.info(`Uploading images`, { + logger.debug(`Uploading images`, { count: opts.images.length, }) const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { opts.onStateChange?.(`Uploading image #${images.length + 1}...`) - logger.info(`Compressing image`) + logger.debug(`Compressing image`) await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - logger.info(`Uploading image`) + logger.debug(`Uploading image`) const res = await uploadBlob(agent, path, 'image/jpeg') images.push({ image: res.data.blob, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index c8e5273d4..e86844395 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20) export const HITSLOP_30 = createHitslop(30) export const BACK_HITSLOP = HITSLOP_30 export const MAX_POST_LINES = 25 + +export const BSKY_FEED_OWNER_DIDS = [ + 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'did:plc:vpkhqolt662uhesyj6nxm7ys', + 'did:plc:q6gjnaw2blty4crticxkmujt', +] diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts new file mode 100644 index 000000000..d1e2de31d --- /dev/null +++ b/src/lib/hooks/useIntentHandler.ts @@ -0,0 +1,91 @@ +import React from 'react' +import * as Linking from 'expo-linking' +import {isNative} from 'platform/detection' +import {useComposerControls} from 'state/shell' +import {useSession} from 'state/session' +import {useCloseAllActiveElements} from 'state/util' + +type IntentType = 'compose' + +const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ + +export function useIntentHandler() { + const incomingUrl = Linking.useURL() + const composeIntent = useComposeIntent() + + React.useEffect(() => { + const handleIncomingURL = (url: string) => { + const urlp = new URL(url) + const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/') + + // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the + // intent check. On web, we have to check the first part of the path since we have an actual hostname + const intentType = isNative ? intentTypeNative : intentTypeWeb + const isIntent = isNative + ? urlp.hostname === 'intent' + : intentTypeNative === 'intent' + const params = urlp.searchParams + + if (!isIntent) return + + switch (intentType as IntentType) { + case 'compose': { + composeIntent({ + text: params.get('text'), + imageUrisStr: params.get('imageUris'), + }) + } + } + } + + if (incomingUrl) handleIncomingURL(incomingUrl) + }, [incomingUrl, composeIntent]) +} + +function useComposeIntent() { + const closeAllActiveElements = useCloseAllActiveElements() + const {openComposer} = useComposerControls() + const {hasSession} = useSession() + + return React.useCallback( + ({ + text, + imageUrisStr, + }: { + text: string | null + imageUrisStr: string | null // unused for right now, will be used later with intents + }) => { + if (!hasSession) return + + closeAllActiveElements() + + const imageUris = imageUrisStr + ?.split(',') + .filter(part => { + // For some security, we're going to filter out any image uri that is external. We don't want someone to + // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg + // and we load that image + if (part.includes('https://') || part.includes('http://')) { + return false + } + // We also should just filter out cases that don't have all the info we need + if (!VALID_IMAGE_REGEX.test(part)) { + return false + } + return true + }) + .map(part => { + const [uri, width, height] = part.split('|') + return {uri, width: Number(width), height: Number(height)} + }) + + setTimeout(() => { + openComposer({ + text: text ?? undefined, + imageUris: isNative ? imageUris : undefined, + }) + }, 500) + }, + [hasSession, closeAllActiveElements, openComposer], + ) +} diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 2195b2304..92543b42c 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -2,19 +2,151 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, moderatePost, + AppBskyActorDefs, + AppBskyFeedPost, + AppBskyRichtextFacet, + AppBskyEmbedImages, } from '@atproto/api' type ModeratePost = typeof moderatePost type Options = Parameters<ModeratePost>[1] & { hiddenPosts?: string[] + mutedWords?: AppBskyActorDefs.MutedWord[] +} + +const REGEX = { + LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu, + ESCAPE: /[[\]{}()*+?.\\^$|\s]/g, + SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g, + WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, +} + +/** + * List of 2-letter lang codes for languages that either don't use spaces, or + * don't use spaces in a way conducive to word-based filtering. + * + * For these, we use a simple `String.includes` to check for a match. + */ +const LANGUAGE_EXCEPTIONS = [ + 'ja', // Japanese + 'zh', // Chinese + 'ko', // Korean + 'th', // Thai + 'vi', // Vietnamese +] + +export function hasMutedWord({ + mutedWords, + text, + facets, + outlineTags, + languages, + isOwnPost, +}: { + mutedWords: AppBskyActorDefs.MutedWord[] + text: string + facets?: AppBskyRichtextFacet.Main[] + outlineTags?: string[] + languages?: string[] + isOwnPost: boolean +}) { + if (isOwnPost) return false + + const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') + const tags = ([] as string[]) + .concat(outlineTags || []) + .concat( + facets + ?.filter(facet => { + return facet.features.find(feature => + AppBskyRichtextFacet.isTag(feature), + ) + }) + .map(t => t.features[0].tag as string) || [], + ) + .map(t => t.toLowerCase()) + + for (const mute of mutedWords) { + const mutedWord = mute.value.toLowerCase() + const postText = text.toLowerCase() + + // `content` applies to tags as well + if (tags.includes(mutedWord)) return true + // rest of the checks are for `content` only + if (!mute.targets.includes('content')) continue + // single character or other exception, has to use includes + if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord)) + return true + // too long + if (mutedWord.length > postText.length) continue + // exact match + if (mutedWord === postText) return true + // any muted phrase with space or punctuation + if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord)) + return true + + // check individual character groups + const words = postText.split(REGEX.WORD_BOUNDARY) + for (const word of words) { + if (word === mutedWord) return true + + // compare word without leading/trailing punctuation, but allow internal + // punctuation (such as `s@ssy`) + const wordTrimmedPunctuation = word.replace( + REGEX.LEADING_TRAILING_PUNCTUATION, + '', + ) + + if (mutedWord === wordTrimmedPunctuation) return true + if (mutedWord.length > wordTrimmedPunctuation.length) continue + + // handle hyphenated, slash separated words, etc + if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) { + // check against full normalized phrase + const wordNormalizedSeparators = wordTrimmedPunctuation.replace( + REGEX.SEPARATORS, + ' ', + ) + const mutedWordNormalizedSeparators = mutedWord.replace( + REGEX.SEPARATORS, + ' ', + ) + // hyphenated (or other sep) to spaced words + if (wordNormalizedSeparators === mutedWordNormalizedSeparators) + return true + + /* Disabled for now e.g. `super-cool` to `supercool` + const wordNormalizedCompressed = wordNormalizedSeparators.replace( + REGEX.WORD_BOUNDARY, + '', + ) + const mutedWordNormalizedCompressed = + mutedWordNormalizedSeparators.replace(/\s+?/g, '') + // hyphenated (or other sep) to non-hyphenated contiguous word + if (mutedWordNormalizedCompressed === wordNormalizedCompressed) + return true + */ + + // then individual parts of separated phrases/words + const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS) + for (const wp of wordParts) { + // still retain internal punctuation + if (wp === mutedWord) return true + } + } + } + } + + return false } export function moderatePost_wrapped( subject: Parameters<ModeratePost>[0], opts: Options, ) { - const {hiddenPosts = [], ...options} = opts + const {hiddenPosts = [], mutedWords = [], ...options} = opts const moderations = moderatePost(subject, options) + const isOwnPost = subject.author.did === opts.userDid if (hiddenPosts.includes(subject.uri)) { moderations.content.filter = true @@ -29,15 +161,86 @@ export function moderatePost_wrapped( } } + if (AppBskyFeedPost.isRecord(subject.record)) { + let muted = hasMutedWord({ + mutedWords, + text: subject.record.text, + facets: subject.record.facets || [], + outlineTags: subject.record.tags || [], + languages: subject.record.langs, + isOwnPost, + }) + + if ( + subject.record.embed && + AppBskyEmbedImages.isMain(subject.record.embed) + ) { + for (const image of subject.record.embed.images) { + muted = + muted || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: subject.record.langs, + isOwnPost, + }) + } + } + + if (muted) { + moderations.content.filter = true + moderations.content.blur = true + if (!moderations.content.cause) { + moderations.content.cause = { + // @ts-ignore Temporary extension to the moderation system -prf + type: 'muted-word', + source: {type: 'user'}, + priority: 1, + } + } + } + } + if (subject.embed) { let embedHidden = false if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { embedHidden = hiddenPosts.includes(subject.embed.record.uri) + + if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { + embedHidden = + embedHidden || + hasMutedWord({ + mutedWords, + text: subject.embed.record.value.text, + facets: subject.embed.record.value.facets, + outlineTags: subject.embed.record.value.tags, + languages: subject.embed.record.value.langs, + isOwnPost, + }) + + if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { + for (const image of subject.embed.record.value.embed.images) { + embedHidden = + embedHidden || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: subject.embed.record.value.langs, + isOwnPost, + }) + } + } + } } if ( AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) ) { + // TODO what embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) } if (embedHidden) { diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index bf19c208a..b6ebb47a0 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -67,6 +67,13 @@ export function describeModerationCause( description: 'You have hidden this post', } } + // @ts-ignore Temporary extension to the moderation system -prf + if (cause.type === 'muted-word') { + return { + name: 'Post hidden by muted word', + description: `You've chosen to hide a word or tag within this post.`, + } + } return cause.labelDef.strings[context].en } diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 538f30cd3..9dfdab909 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -25,3 +25,13 @@ export function makeCustomFeedLink( export function makeListLink(did: string, rkey: string, ...segments: string[]) { return [`/profile`, did, 'lists', rkey, ...segments].join('/') } + +export function makeTagLink(did: string) { + return `/search?q=${encodeURIComponent(did)}` +} + +export function makeSearchLink(props: {query: string; from?: 'me' | string}) { + return `/search?q=${encodeURIComponent( + props.query + (props.from ? ` from:${props.from}` : ''), + )}` +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0fb36fa7c..0ec09f610 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -33,6 +33,7 @@ export type CommonNavigatorParams = { PreferencesFollowingFeed: undefined PreferencesThreads: undefined PreferencesExternalEmbeds: undefined + Search: {q?: string} } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index e2abe9019..de4562d2c 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string { return base + 's' } -export function enforceLen(str: string, len: number, ellipsis = false): string { +export function enforceLen( + str: string, + len: number, + ellipsis = false, + mode: 'end' | 'middle' = 'end', +): string { str = str || '' if (str.length > len) { - return str.slice(0, len) + (ellipsis ? '...' : '') + if (ellipsis) { + if (mode === 'end') { + return str.slice(0, len) + '…' + } else if (mode === 'middle') { + const half = Math.floor(len / 2) + return str.slice(0, half) + '…' + str.slice(-half) + } else { + // fallback + return str.slice(0, len) + } + } else { + return str.slice(0, len) + } } return str } |