diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index f4da22ca..f13d568b 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -202,6 +202,7 @@ func serve(cctx *cli.Context) error { e.GET("/support/tos", server.WebGeneric) e.GET("/support/community-guidelines", server.WebGeneric) e.GET("/support/copyright", server.WebGeneric) + e.GET("/intent/compose", server.WebGeneric) // profile endpoints; only first populates info e.GET("/profile/:handleOrDID", server.WebProfile) diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift index a16a290b..4c1d635c 100644 --- a/modules/Share-with-Bluesky/ShareViewController.swift +++ b/modules/Share-with-Bluesky/ShareViewController.swift @@ -119,7 +119,7 @@ class ShareViewController: UIViewController { // extension does. if let dir = FileManager() .containerURL( - forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier?.replacingOccurrences(of: ".Share-with-Bluesky", with: "") ?? "")") + forSecurityApplicationGroupIdentifier: "group.app.bsky") { let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" diff --git a/modules/react-native-ui-text-view/ios/RNUITextView.swift b/modules/react-native-ui-text-view/ios/RNUITextView.swift index 9c21d45b..3fb55873 100644 --- a/modules/react-native-ui-text-view/ios/RNUITextView.swift +++ b/modules/react-native-ui-text-view/ios/RNUITextView.swift @@ -108,14 +108,26 @@ class RNUITextView: UIView { fractionOfDistanceBetweenInsertionPoints: nil ) + var lastUpperBound: String.Index? = nil for child in self.reactSubviews() { if let child = child as? RNUITextViewChild, let childText = child.text { let fullText = self.textView.attributedText.string - let range = fullText.range(of: childText) - + + // We want to skip over the children we have already checked, otherwise we could run into + // collisions of similar strings (i.e. links that get shortened to the same hostname but + // different paths) + let range = fullText.range(of: childText, options: [], range: (lastUpperBound ?? String.Index(utf16Offset: 0, in: fullText) )..= lowerBound.utf16Offset(in: fullText) && charIndex <= upperBound.utf16Offset(in: fullText) { + let lowerOffset = lowerBound.utf16Offset(in: fullText) + let upperOffset = upperBound.utf16Offset(in: fullText) + + if charIndex >= lowerOffset, + charIndex <= upperOffset + { return child + } else { + lastUpperBound = upperBound } } } diff --git a/src/alf/util/platform.ts b/src/alf/util/platform.ts index 544f5480..294e08a8 100644 --- a/src/alf/util/platform.ts +++ b/src/alf/util/platform.ts @@ -1,25 +1,25 @@ -import {Platform} from 'react-native' +import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection' export function web(value: any) { - return Platform.select({ - web: value, - }) + if (isWeb) { + return value + } } export function ios(value: any) { - return Platform.select({ - ios: value, - }) + if (isIOS) { + return value + } } export function android(value: any) { - return Platform.select({ - android: value, - }) + if (isAndroid) { + return value + } } export function native(value: any) { - return Platform.select({ - native: value, - }) + if (isNative) { + return value + } } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 27f43afd..5c035027 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -11,6 +11,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useTheme, atoms as a, flatten} from '#/alf' import {Portal} from '#/components/Portal' import {createInput} from '#/components/forms/TextField' +import {logger} from '#/logger' import { DialogOuterProps, @@ -56,7 +57,7 @@ export function Outer({ ) const close = React.useCallback(cb => { - if (cb) { + if (cb && typeof cb === 'function') { closeCallback.current = cb } sheet.current?.close() @@ -74,8 +75,16 @@ export function Outer({ const onChange = React.useCallback( (index: number) => { if (index === -1) { - closeCallback.current?.() - closeCallback.current = undefined + try { + closeCallback.current?.() + } catch (e: any) { + logger.error(`Dialog closeCallback failed`, { + message: e.message, + }) + } finally { + closeCallback.current = undefined + } + onClose?.() setOpenIndex(-1) } diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index fa29fbd6..ff05fed9 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -190,7 +190,7 @@ export function Close() { variant="ghost" color="secondary" shape="round" - onPress={close} + onPress={() => close()} label={_(msg`Close active dialog`)}> diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 75ba825a..161c0373 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -6,8 +6,13 @@ import {ViewStyleProp} from '#/alf' type A11yProps = Required +export type DialogControlProps = { + open: (options?: DialogControlOpenOptions) => void + close: (callback?: () => void) => void +} + export type DialogContextProps = { - close: () => void + close: DialogControlProps['close'] } export type DialogControlOpenOptions = { @@ -20,11 +25,6 @@ export type DialogControlOpenOptions = { index?: number } -export type DialogControlProps = { - open: (options?: DialogControlOpenOptions) => void - close: (callback?: () => void) => void -} - export type DialogOuterProps = { control: { ref: React.RefObject diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 41167910..8e55bd83 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -89,7 +89,7 @@ export function Cancel({ color="secondary" size="small" label={_(msg`Cancel`)} - onPress={close}> + onPress={() => close()}> {children} ) diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts index 1d907963..c35c1ef7 100644 --- a/src/lib/__tests__/moderatePost_wrapped.test.ts +++ b/src/lib/__tests__/moderatePost_wrapped.test.ts @@ -11,12 +11,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'outlineTag', targets: ['tag']}], - rt.text, - rt.facets, - ['outlineTag'], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'outlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) expect(match).toBe(true) }) @@ -27,12 +27,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'inlineTag', targets: ['tag']}], - rt.text, - rt.facets, - ['outlineTag'], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) expect(match).toBe(true) }) @@ -43,12 +43,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'inlineTag', targets: ['content']}], - rt.text, - rt.facets, - ['outlineTag'], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: ['outlineTag'], + }) expect(match).toBe(true) }) @@ -59,12 +59,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'inlineTag', targets: ['tag']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'inlineTag', targets: ['tag']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -80,12 +80,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'ๅธŒ', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'ๅธŒ', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -96,12 +96,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'politics', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'politics', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -112,12 +112,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'javascript', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'javascript', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -130,12 +130,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'javascript', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'javascript', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -146,12 +146,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'ai', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'ai', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -162,12 +162,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: 'brain', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'brain', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -178,12 +178,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: `:)`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `:)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -197,23 +197,23 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: yay!`, () => { - const match = hasMutedWord( - [{value: 'yay!', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'yay!', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: yay`, () => { - const match = hasMutedWord( - [{value: 'yay', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'yay', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -226,24 +226,24 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: y!ppee`, () => { - const match = hasMutedWord( - [{value: 'y!ppee', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'y!ppee', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) // single exclamation point, source has double it(`no match: y!ppee!`, () => { - const match = hasMutedWord( - [{value: 'y!ppee!', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'y!ppee!', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -256,23 +256,23 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: S@assy`, () => { - const match = hasMutedWord( - [{value: 'S@assy', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'S@assy', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: s@assy`, () => { - const match = hasMutedWord( - [{value: 's@assy', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 's@assy', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -286,12 +286,12 @@ describe(`hasMutedWord`, () => { // case insensitive it(`match: new york times`, () => { - const match = hasMutedWord( - [{value: 'new york times', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'new york times', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -304,23 +304,23 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: !command`, () => { - const match = hasMutedWord( - [{value: `!command`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `!command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: command`, () => { - const match = hasMutedWord( - [{value: `command`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -331,12 +331,12 @@ describe(`hasMutedWord`, () => { }) rt.detectFacetsWithoutResolution() - const match = hasMutedWord( - [{value: `!command`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `!command`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -349,23 +349,23 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: e/acc`, () => { - const match = hasMutedWord( - [{value: `e/acc`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `e/acc`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: acc`, () => { - const match = hasMutedWord( - [{value: `acc`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `acc`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -378,45 +378,45 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: super-bad`, () => { - const match = hasMutedWord( - [{value: `super-bad`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `super-bad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: super`, () => { - const match = hasMutedWord( - [{value: `super`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `super`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: super bad`, () => { - const match = hasMutedWord( - [{value: `super bad`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `super bad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: superbad`, () => { - const match = hasMutedWord( - [{value: `superbad`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `superbad`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -429,47 +429,49 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: idk what this would be`, () => { - const match = hasMutedWord( - [{value: `idk what this would be`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `idk what this would be`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`no match: idk what this would be for`, () => { // extra word - const match = hasMutedWord( - [{value: `idk what this would be for`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [ + {value: `idk what this would be for`, targets: ['content']}, + ], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) it(`match: idk`, () => { // extra word - const match = hasMutedWord( - [{value: `idk`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `idk`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: idkwhatthiswouldbe`, () => { - const match = hasMutedWord( - [{value: `idkwhatthiswouldbe`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(false) }) @@ -482,45 +484,45 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: context(iykyk)`, () => { - const match = hasMutedWord( - [{value: `context(iykyk)`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `context(iykyk)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: context`, () => { - const match = hasMutedWord( - [{value: `context`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `context`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: iykyk`, () => { - const match = hasMutedWord( - [{value: `iykyk`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `iykyk`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: (iykyk)`, () => { - const match = hasMutedWord( - [{value: `(iykyk)`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `(iykyk)`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -533,12 +535,12 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: ๐Ÿฆ‹`, () => { - const match = hasMutedWord( - [{value: `๐Ÿฆ‹`, targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: `๐Ÿฆ‹`, targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) @@ -553,23 +555,46 @@ describe(`hasMutedWord`, () => { rt.detectFacetsWithoutResolution() it(`match: stop worrying`, () => { - const match = hasMutedWord( - [{value: 'stop worrying', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'stop worrying', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) expect(match).toBe(true) }) it(`match: turtles, or how`, () => { - const match = hasMutedWord( - [{value: 'turtles, or how', targets: ['content']}], - rt.text, - rt.facets, - [], - ) + const match = hasMutedWord({ + mutedWords: [{value: 'turtles, or how', targets: ['content']}], + text: rt.text, + facets: rt.facets, + outlineTags: [], + }) + + 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'], + }) expect(match).toBe(true) }) diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index de9a96da..d1e2de31 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -3,6 +3,7 @@ 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' @@ -42,6 +43,7 @@ export function useIntentHandler() { } function useComposeIntent() { + const closeAllActiveElements = useCloseAllActiveElements() const {openComposer} = useComposerControls() const {hasSession} = useSession() @@ -55,6 +57,8 @@ function useComposeIntent() { }) => { if (!hasSession) return + closeAllActiveElements() + const imageUris = imageUrisStr ?.split(',') .filter(part => { @@ -82,6 +86,6 @@ function useComposeIntent() { }) }, 500) }, - [openComposer, hasSession], + [hasSession, closeAllActiveElements, openComposer], ) } diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 862f2de6..428dbabf 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -21,12 +21,34 @@ const REGEX = { WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g, } -export function hasMutedWord( - mutedWords: AppBskyActorDefs.MutedWord[], - text: string, - facets?: AppBskyRichtextFacet.Main[], - outlineTags?: string[], -) { +/** + * 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, +}: { + mutedWords: AppBskyActorDefs.MutedWord[] + text: string + facets?: AppBskyRichtextFacet.Main[] + outlineTags?: string[] + languages?: string[] +}) { + const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '') const tags = ([] as string[]) .concat(outlineTags || []) .concat( @@ -48,8 +70,9 @@ export function hasMutedWord( if (tags.includes(mutedWord)) return true // rest of the checks are for `content` only if (!mute.targets.includes('content')) continue - // single character, has to use includes - if (mutedWord.length === 1 && postText.includes(mutedWord)) return true + // 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 @@ -134,19 +157,28 @@ export function moderatePost_wrapped( } if (AppBskyFeedPost.isRecord(subject.record)) { - let muted = hasMutedWord( + let muted = hasMutedWord({ mutedWords, - subject.record.text, - subject.record.facets || [], - subject.record.tags || [], - ) + text: subject.record.text, + facets: subject.record.facets || [], + outlineTags: subject.record.tags || [], + languages: subject.record.langs, + }) if ( subject.record.embed && AppBskyEmbedImages.isMain(subject.record.embed) ) { for (const image of subject.record.embed.images) { - muted = muted || hasMutedWord(mutedWords, image.alt, [], []) + muted = + muted || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: subject.record.langs, + }) } } @@ -172,17 +204,25 @@ export function moderatePost_wrapped( if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { embedHidden = embedHidden || - hasMutedWord( + hasMutedWord({ mutedWords, - subject.embed.record.value.text, - subject.embed.record.value.facets, - subject.embed.record.value.tags, - ) + text: subject.embed.record.value.text, + facets: subject.embed.record.value.facets, + outlineTags: subject.embed.record.value.tags, + languages: subject.embed.record.value.langs, + }) if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { for (const image of subject.embed.record.value.embed.images) { embedHidden = - embedHidden || hasMutedWord(mutedWords, image.alt, [], []) + embedHidden || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: subject.embed.record.value.langs, + }) } } } diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx index c3d61640..9d9cc560 100644 --- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx +++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx @@ -118,7 +118,7 @@ export function AdultContentEnabledPref({ - + prompt.close()}> OK diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 67294ece..1fa92c29 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -1,11 +1,9 @@ -import React from 'react' import { useQuery, useInfiniteQuery, InfiniteData, QueryKey, useMutation, - useQueryClient, } from '@tanstack/react-query' import { AtUri, @@ -15,7 +13,6 @@ import { AppBskyUnspeccedGetPopularFeedGenerators, } from '@atproto/api' -import {logger} from '#/logger' import {router} from '#/routes' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' @@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = { likeUri: '', } -export function usePinnedFeedsInfos(): { - feeds: FeedSourceInfo[] - hasPinnedCustom: boolean - isLoading: boolean -} { - const queryClient = useQueryClient() - const [tabs, setTabs] = React.useState([ - FOLLOWING_FEED_STUB, - ]) - const [isLoading, setLoading] = React.useState(true) - const {data: preferences} = usePreferencesQuery() +export function usePinnedFeedsInfos() { + const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery() + const pinnedUris = preferences?.feeds?.pinned ?? [] - const hasPinnedCustom = React.useMemo(() => { - return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) - }, [tabs]) + return useQuery({ + staleTime: STALE.INFINITY, + enabled: !isLoadingPrefs, + queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')], + queryFn: async () => { + let resolved = new Map() - React.useEffect(() => { - if (!preferences?.feeds?.pinned) return - const uris = preferences.feeds.pinned - - async function fetchFeedInfo() { - const reqs = [] - - for (const uri of uris) { - const cached = queryClient.getQueryData( - feedSourceInfoQueryKey({uri}), - ) - - if (cached) { - reqs.push(cached) - } else { - reqs.push( - (async () => { - // these requests can fail, need to filter those out - try { - return await queryClient.fetchQuery({ - staleTime: STALE.SECONDS.FIFTEEN, - queryKey: feedSourceInfoQueryKey({uri}), - queryFn: async () => { - const type = getFeedTypeFromUri(uri) - - if (type === 'feed') { - const res = - await getAgent().app.bsky.feed.getFeedGenerator({ - feed: uri, - }) - return hydrateFeedGenerator(res.data.view) - } else { - const res = await getAgent().app.bsky.graph.getList({ - list: uri, - limit: 1, - }) - return hydrateList(res.data.list) - } - }, - }) - } catch (e) { - // expected failure - logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, { - error: e, - }) - } - })(), - ) - } + // Get all feeds. We can do this in a batch. + const feedUris = pinnedUris.filter( + uri => getFeedTypeFromUri(uri) === 'feed', + ) + let feedsPromise = Promise.resolve() + if (feedUris.length > 0) { + feedsPromise = getAgent() + .app.bsky.feed.getFeedGenerators({ + feeds: feedUris, + }) + .then(res => { + for (let feedView of res.data.feeds) { + resolved.set(feedView.uri, hydrateFeedGenerator(feedView)) + } + }) } - const views = (await Promise.all(reqs)).filter( - Boolean, - ) as FeedSourceInfo[] + // Get all lists. This currently has to be done individually. + const listUris = pinnedUris.filter( + uri => getFeedTypeFromUri(uri) === 'list', + ) + const listsPromises = listUris.map(listUri => + getAgent() + .app.bsky.graph.getList({ + list: listUri, + limit: 1, + }) + .then(res => { + const listView = res.data.list + resolved.set(listView.uri, hydrateList(listView)) + }), + ) - setTabs([FOLLOWING_FEED_STUB].concat(views)) - setLoading(false) - } - - fetchFeedInfo() - }, [queryClient, setTabs, preferences?.feeds?.pinned]) - - return {feeds: tabs, hasPinnedCustom, isLoading} + // The returned result will have the original order. + const result = [FOLLOWING_FEED_STUB] + await Promise.allSettled([feedsPromise, ...listsPromises]) + for (let pinnedUri of pinnedUris) { + if (resolved.has(pinnedUri)) { + result.push(resolved.get(pinnedUri)) + } + } + return result + }, + }) } diff --git a/src/state/util.ts b/src/state/util.ts index 57f4331b..7b49b5b4 100644 --- a/src/state/util.ts +++ b/src/state/util.ts @@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox' import {useModalControls} from './modals' import {useComposerControls} from './shell/composer' import {useSetDrawerOpen} from './shell/drawer-open' +import {useDialogStateControlContext} from 'state/dialogs' /** * returns true if something was closed @@ -35,11 +36,19 @@ export function useCloseAllActiveElements() { const {closeLightbox} = useLightboxControls() const {closeAllModals} = useModalControls() const {closeComposer} = useComposerControls() + const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext() const setDrawerOpen = useSetDrawerOpen() return useCallback(() => { closeLightbox() closeAllModals() closeComposer() + closeAlfDialogs() setDrawerOpen(false) - }, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen]) + }, [ + closeLightbox, + closeAllModals, + closeComposer, + closeAlfDialogs, + setDrawerOpen, + ]) } diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index 3df3858b..bbd16465 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -1,7 +1,7 @@ import React from 'react' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {HomeHeaderLayout} from './HomeHeaderLayout' -import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {FeedSourceInfo} from '#/state/queries/feed' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {isWeb} from 'platform/detection' @@ -9,15 +9,22 @@ import {TabBar} from '../pager/TabBar' import {usePalette} from '#/lib/hooks/usePalette' export function HomeHeader( - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, + props: RenderTabBarFnProps & { + testID?: string + onPressSelected: () => void + feeds: FeedSourceInfo[] + }, ) { + const {feeds} = props const navigation = useNavigation() - const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const pal = usePalette('default') + const hasPinnedCustom = React.useMemo(() => { + return feeds.some(tab => tab.uri !== '') + }, [feeds]) + const items = React.useMemo(() => { const pinnedNames = feeds.map(f => f.displayName) - if (!hasPinnedCustom) { return pinnedNames.concat('Feeds โœจ') } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 856c237f..7ad9beb5 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -17,11 +17,12 @@ import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {emitSoftReset} from '#/state/events' import {useSession} from '#/state/session' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' +import {useSetTitle} from '#/lib/hooks/useSetTitle' type Props = NativeStackScreenProps export function HomeScreen(props: Props) { const {data: preferences} = usePreferencesQuery() - const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = + const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = usePinnedFeedsInfos() if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { return ( @@ -66,6 +67,8 @@ function HomeScreenReady({ const selectedIndex = Math.max(0, maybeFoundIndex) const selectedFeed = allFeeds[selectedIndex] + useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) + const pagerRef = React.useRef(null) const lastPagerReportedIndexRef = React.useRef(selectedIndex) React.useLayoutEffect(() => { @@ -124,10 +127,11 @@ function HomeScreenReady({ onSelect={props.onSelect} testID="homeScreenFeedTabs" onPressSelected={onPressSelected} + feeds={pinnedFeedInfos} /> ) }, - [onPressSelected], + [onPressSelected, pinnedFeedInfos], ) const renderFollowingEmptyState = React.useCallback(() => { diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index c3b1caa3..f447490b 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -15,7 +15,7 @@ import {emitSoftReset} from '#/state/events' export function DesktopFeeds() { const pal = usePalette('default') const {_} = useLingui() - const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos() + const {data: pinnedFeedInfos} = usePinnedFeedsInfos() const selectedFeed = useSelectedFeed() const setSelectedFeed = useSetSelectedFeed() const navigation = useNavigation() @@ -25,7 +25,9 @@ export function DesktopFeeds() { } return getCurrentRoute(state) }) - + if (!pinnedFeedInfos) { + return null + } return ( {pinnedFeedInfos.map(feedInfo => {