From 88846ca36d2231df8e65a665ab4530761ab9e6ed Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 26 Feb 2024 16:48:32 -0800 Subject: [PATCH 01/11] =?UTF-8?q?fix=20reversed=20icons=20in=20validator?= =?UTF-8?q?=20=F0=9F=A4=A6=20(#2991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view/com/auth/create/Step2.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index a3892030..5c262977 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) { const t = useTheme() if (!valid) { - return + return } - return + return } From 832582df23bb2d2b0009d24a6c2bef7a5a388e8f Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 26 Feb 2024 16:54:05 -0800 Subject: [PATCH 02/11] Adjust `windowSize` on `PostThread` `FlatList` (#2989) * adjust window size, cells batching period * rm batching period change --- src/view/com/post-thread/PostThread.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 434f018f..a7ee42a9 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -437,6 +437,7 @@ function PostThreadLoaded({ // @ts-ignore our .web version only -prf desktopFixedHeight removeClippedSubviews={isAndroid ? false : undefined} + windowSize={11} /> ) } From 9b11fc720f1714c7cfba76daaf3cefaf0ae97ae6 Mon Sep 17 00:00:00 2001 From: Steve Klabnik Date: Mon, 26 Feb 2024 19:24:14 -0600 Subject: [PATCH 03/11] Remove invite codes from README (#2988) Invite codes aren't needed anymore! Thanks to https://bsky.app/profile/shreyanjain.net/post/3kmdulzeibc2o for pointing this out to me. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 0cbfe773..49c4b016 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,6 @@ If you discover any security issues, please send an email to security@bsky.app. Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more. -If you're a developer interested in building on atproto, we'd love to email you a Bluesky invite code. Simply share your GitHub (or similar) profile with us via [this form](https://forms.gle/BF21oxVNZiDjDhXF9). - ## License (MIT) See [./LICENSE](./LICENSE) for the full license. From 0311288dd76026cf1bab5302b01637bdeb9c07e2 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 26 Feb 2024 19:24:36 -0600 Subject: [PATCH 04/11] Fix dark butterfly on android splash (#2993) --- src/Splash.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Splash.tsx b/src/Splash.tsx index b2381f92..42a21c08 100644 --- a/src/Splash.tsx +++ b/src/Splash.tsx @@ -181,6 +181,8 @@ export function Splash(props: React.PropsWithChildren) { const logoAnimations = reduceMotion === true ? reducedLogoAnimation : logoAnimation + // special off-spec color for dark mode + const logoBg = isDarkMode ? '#0F1824' : '#fff' return ( @@ -232,7 +234,7 @@ export function Splash(props: React.PropsWithChildren) { }, ]}> @@ -253,7 +255,7 @@ export function Splash(props: React.PropsWithChildren) { transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px }, ]}> - + }> {!isAnimationComplete && ( @@ -261,10 +263,7 @@ export function Splash(props: React.PropsWithChildren) { style={[ StyleSheet.absoluteFillObject, { - backgroundColor: isDarkMode - ? // special off-spec color for dark mode - '#0F1824' - : '#fff', + backgroundColor: logoBg, }, ]} /> From a8925bac8616dccadc016304e98091d54f838042 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 27 Feb 2024 01:25:25 +0000 Subject: [PATCH 05/11] Remove side borders on mobile web list (#2994) --- src/view/com/util/List.web.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 29bad2db..936bac19 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -172,7 +172,7 @@ function ListImpl( ( const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) const styles = StyleSheet.create({ - contentContainer: { + sideBorders: { borderLeftWidth: 1, borderRightWidth: 1, }, From e9ad3f552d072ed2c818bcb28a90b46ee2027af7 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 26 Feb 2024 17:26:41 -0800 Subject: [PATCH 06/11] Bump version to 1.70 (wait for release) (#2987) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5c31f10f..3f007a23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.69.0", + "version": "1.70.0", "private": true, "engines": { "node": ">=18" From 771999761e9a69d4f884c1c75cf20fde5d149791 Mon Sep 17 00:00:00 2001 From: Vinayak Kulkarni Date: Tue, 27 Feb 2024 07:05:06 +0530 Subject: [PATCH 07/11] feat(nvm): add support for nvm & minor actions housekeeping (#2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add `.nvmrc` for specifying node version Co-Authored-By: Evandro Leopoldino Gonçalves * chore(deps): bump setup-node to v4 * build: use node-version-file instead of hardcoded 18.x * chore(deps): bump nvm version * fix: checkout before install 🎉 --------- Co-authored-by: Evandro Leopoldino Gonçalves --- .github/workflows/build-submit-android.yml | 4 ++-- .github/workflows/build-submit-ios.yml | 4 ++-- .github/workflows/lint.yml | 8 ++++---- .nvmrc | 1 + Dockerfile | 2 +- bskyweb/README.md | 6 +++--- 6 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 051e9515..6fa177fb 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -26,9 +26,9 @@ jobs: uses: actions/checkout@v4 - name: 🔧 Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version-file: .nvmrc cache: yarn - name: 🔨 Setup EAS diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index 0fd691bb..b6767806 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -28,9 +28,9 @@ jobs: uses: actions/checkout@v4 - name: 🔧 Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version-file: .nvmrc cache: yarn - name: 🔨 Setup EAS diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 508da536..9aa55ca0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,12 +32,12 @@ jobs: name: Run tests runs-on: ubuntu-latest steps: - - name: Install node 18 - uses: actions/setup-node@v4 - with: - node-version: 18 - name: Check out Git repository uses: actions/checkout@v3 + - name: Install node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc - name: Yarn install uses: Wandalen/wretry.action@master with: diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..3c032078 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/Dockerfile b/Dockerfile index 47afa61a..fcd2413c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ COPY . . RUN mkdir --parents $NVM_DIR && \ wget \ --output-document=/tmp/nvm-install.sh \ - https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh && \ + https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \ bash /tmp/nvm-install.sh RUN \. "$NVM_DIR/nvm.sh" && \ diff --git a/bskyweb/README.md b/bskyweb/README.md index c8efe044..640c30f4 100644 --- a/bskyweb/README.md +++ b/bskyweb/README.md @@ -6,9 +6,9 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development environment set up. Either follow the top-level README, or something quick like: - # install nodejs 18 (specifically) - nvm install 18 - nvm use 18 + # install nodejs + nvm install + nvm use npm install --global yarn # setup tools and deps (in top level of this repo) From 3ec4e034b79950b0d2ce43b6048dee3dd37c3747 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 27 Feb 2024 02:33:23 +0000 Subject: [PATCH 08/11] Fix imprecise header height calculation (#2995) * Remove dead code * Fix header height calculation --- src/view/com/feeds/FeedPage.tsx | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 60814e83..2aacdb89 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -200,21 +200,12 @@ export function FeedPage({ function useHeaderOffset() { const {isDesktop, isTablet} = useWebMediaQueries() const {fontScale} = useWindowDimensions() - const {hasSession} = useSession() if (isDesktop || isTablet) { return 0 } - if (hasSession) { - const navBarPad = 16 - const navBarText = 21 * fontScale - const tabBarPad = 20 + 3 // nav bar padding + border - const tabBarText = 16 * fontScale - const magic = 7 * fontScale - return navBarPad + navBarText + tabBarPad + tabBarText + magic - } else { - const navBarPad = 16 - const navBarText = 21 * fontScale - const magic = 4 * fontScale - return navBarPad + navBarText + magic - } + const navBarHeight = 42 + const tabBarPad = 10 + 10 + 3 // padding + border + const normalLineHeight = 1.2 + const tabBarText = 16 * normalLineHeight * fontScale + return navBarHeight + tabBarPad + tabBarText } From 1a349216612d3c36edef47cac6bbd4b72a34ab38 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 27 Feb 2024 03:56:25 +0000 Subject: [PATCH 09/11] Tweak tabbar sizing on web (#2996) --- src/view/com/pager/TabBar.tsx | 49 ++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index dadcfceb..3204bb23 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -4,7 +4,6 @@ import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {isWeb} from 'platform/detection' import {DraggableScrollView} from './DraggableScrollView' export interface TabBarProps { @@ -32,13 +31,15 @@ export function TabBar({ [indicatorColor, pal], ) const {isDesktop, isTablet} = useWebMediaQueries() + const styles = isDesktop || isTablet ? desktopStyles : mobileStyles // scrolls to the selected item when the page changes useEffect(() => { scrollElRef.current?.scrollTo({ - x: itemXs[selectedPage] || 0, + x: + (itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal, }) - }, [scrollElRef, itemXs, selectedPage]) + }, [scrollElRef, itemXs, selectedPage, styles]) const onPressItem = useCallback( (index: number) => { @@ -63,8 +64,6 @@ export function TabBar({ [], ) - const styles = isDesktop || isTablet ? desktopStyles : mobileStyles - return ( onItemLayout(e, i)} - style={[styles.item, selected && indicatorStyle]} + style={styles.item} hoverStyle={pal.viewLight} onPress={() => onPressItem(i)}> - - {item} - + + + {item} + + ) })} @@ -103,18 +104,18 @@ const desktopStyles = StyleSheet.create({ width: 598, }, contentContainer: { - columnGap: 8, - marginLeft: 14, - paddingRight: 14, + paddingHorizontal: 0, backgroundColor: 'transparent', }, item: { paddingTop: 14, + paddingHorizontal: 14, + justifyContent: 'center', + }, + itemInner: { paddingBottom: 12, - paddingHorizontal: 10, borderBottomWidth: 3, borderBottomColor: 'transparent', - justifyContent: 'center', }, }) @@ -123,17 +124,17 @@ const mobileStyles = StyleSheet.create({ flexDirection: 'row', }, contentContainer: { - columnGap: isWeb ? 0 : 20, - marginLeft: isWeb ? 0 : 18, - paddingRight: isWeb ? 0 : 36, backgroundColor: 'transparent', + paddingHorizontal: 8, }, item: { paddingTop: 10, - paddingBottom: 10, - paddingHorizontal: isWeb ? 8 : 0, - borderBottomWidth: 3, - borderBottomColor: 'transparent', + paddingHorizontal: 10, justifyContent: 'center', }, + itemInner: { + paddingBottom: 10, + borderBottomWidth: 3, + borderBottomColor: 'transparent', + }, }) From c8582924e2421e5383050c4f60a80d2e74287c07 Mon Sep 17 00:00:00 2001 From: Hailey Date: Mon, 26 Feb 2024 20:19:06 -0800 Subject: [PATCH 10/11] Scale back sentry logs for trivial events (#2997) * scale back sentry logs * update migrate log tests --- src/lib/api/index.ts | 6 +++--- src/screens/Onboarding/state.ts | 2 +- src/state/persisted/__tests__/migrate.test.ts | 6 +++--- src/state/persisted/index.ts | 6 +++--- src/state/persisted/legacy.ts | 8 ++++---- src/state/session/index.tsx | 18 +++++++++--------- src/view/com/auth/login/LoginForm.tsx | 2 +- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 440dfa5e..5fb7fe50 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/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index bd8205ca..969edbdd 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -232,7 +232,7 @@ export function reducer( }) if (s.activeStep !== state.activeStep) { - logger.info(`onboarding: step changed`, {activeStep: state.activeStep}) + logger.debug(`onboarding: step changed`, {activeStep: state.activeStep}) } return state diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts index e4b55d5d..97767e27 100644 --- a/src/state/persisted/__tests__/migrate.test.ts +++ b/src/state/persisted/__tests__/migrate.test.ts @@ -26,7 +26,7 @@ test('migrate: fresh install', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => { await migrate() expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) - expect(logger.info).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( 'persisted state: migrated legacy storage', ) }) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 2f34c2db..f57172d2 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -19,7 +19,7 @@ const _emitter = new EventEmitter() * the Provider. */ export async function init() { - logger.info('persisted state: initializing') + logger.debug('persisted state: initializing') broadcast.onmessage = onBroadcastMessage @@ -27,11 +27,11 @@ export async function init() { await migrate() // migrate old store const stored = await store.read() // check for new store if (!stored) { - logger.info('persisted state: initializing default storage') + logger.debug('persisted state: initializing default storage') await store.write(defaults) // opt: init new store } _state = stored || defaults // return new store - logger.log('persisted state: initialized') + logger.debug('persisted state: initialized') } catch (e) { logger.error('persisted state: failed to load root state from storage', { message: e, diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index cce080c8..fd94a96a 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -121,7 +121,7 @@ export function transform(legacy: Partial): Schema { * local storage AND old storage exists. */ export async function migrate() { - logger.info('persisted state: check need to migrate') + logger.debug('persisted state: check need to migrate') try { const rawLegacyData = await AsyncStorage.getItem( @@ -131,7 +131,7 @@ export async function migrate() { const alreadyMigrated = Boolean(newData) if (!alreadyMigrated && rawLegacyData) { - logger.info('persisted state: migrating legacy storage') + logger.debug('persisted state: migrating legacy storage') const legacyData = JSON.parse(rawLegacyData) const newData = transform(legacyData) @@ -139,14 +139,14 @@ export async function migrate() { if (validate.success) { await write(newData) - logger.info('persisted state: migrated legacy storage') + logger.debug('persisted state: migrated legacy storage') } else { logger.error('persisted state: legacy data failed validation', { message: validate.error, }) } } else { - logger.info('persisted state: no migration needed') + logger.debug('persisted state: no migration needed') } } catch (e: any) { logger.error(e, { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index bd3b157b..46628318 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -133,7 +133,7 @@ function createPersistSessionHandler( accessJwt: session?.accessJwt, } - logger.info(`session: persistSession`, { + logger.debug(`session: persistSession`, { event, deactivated: refreshedAccount.deactivated, }) @@ -320,7 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) const logout = React.useCallback(async () => { - logger.info(`session: logout`) + logger.debug(`session: logout`) clearCurrentAccount() setStateAndPersist(s => { return { @@ -374,7 +374,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } if (canReusePrevSession) { - logger.info(`session: attempting to reuse previous session`) + logger.debug(`session: attempting to reuse previous session`) agent.session = prevSession __globalAgent = agent @@ -384,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (prevSession.deactivated) { // don't attempt to resume // use will be taken to the deactivated screen - logger.info(`session: reusing session for deactivated account`) + logger.debug(`session: reusing session for deactivated account`) return } @@ -410,7 +410,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { __globalAgent = PUBLIC_BSKY_AGENT }) } else { - logger.info(`session: attempting to resume using previous session`) + logger.debug(`session: attempting to resume using previous session`) try { const freshAccount = await resumeSessionWithFreshAccount() @@ -431,7 +431,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } async function resumeSessionWithFreshAccount(): Promise { - logger.info(`session: resumeSessionWithFreshAccount`) + logger.debug(`session: resumeSessionWithFreshAccount`) await networkRetry(1, () => agent.resumeSession(prevSession)) @@ -552,11 +552,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return persisted.onUpdate(() => { const session = persisted.get('session') - logger.info(`session: persisted onUpdate`, {}) + logger.debug(`session: persisted onUpdate`, {}) if (session.currentAccount && session.currentAccount.refreshJwt) { if (session.currentAccount?.did !== state.currentAccount?.did) { - logger.info(`session: persisted onUpdate, switching accounts`, { + logger.debug(`session: persisted onUpdate, switching accounts`, { from: { did: state.currentAccount?.did, handle: state.currentAccount?.handle, @@ -569,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { initSession(session.currentAccount) } else { - logger.info(`session: persisted onUpdate, updating session`, {}) + logger.debug(`session: persisted onUpdate, updating session`, {}) /* * Use updated session in this tab's agent. Do not call diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index e480de7a..fdba9f20 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -107,7 +107,7 @@ export const LoginForm = ({ const errMsg = e.toString() setIsProcessing(false) if (errMsg.includes('Authentication Required')) { - logger.info('Failed to login due to invalid credentials', { + logger.debug('Failed to login due to invalid credentials', { error: errMsg, }) setError(_(msg`Invalid username or password`)) From 58aaad704aa971c5ebbf5a5f330a2e2129b557f6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 26 Feb 2024 22:33:48 -0600 Subject: [PATCH 11/11] Add tags and mute words (#2968) * Add bare minimum hashtags support (#2804) * Add bare minimum hashtags support As atproto/api already parses hashtags, this is as simple as hooking it up like link segments. This is "bare minimum" because: - Opening hashtag "#foo" is actually just a search for "foo" right now to work around #2491. - There is no integration in the composer. This hasn't stopped people from using hashtags already, and can be added later. - This change itself only had to hook things up - thank you for having already put the hashtag parsing in place. * Remove workaround for hash search not working now that it's fixed * Add RichTextTag and TagMenu * Sketch * Remove hackfix * Some cleanup * Sketch web * Mobile design * Mobile handling of tags search * Web only * Fix navigation woes * Use new callback * Hook it up * Integrate muted tags * Fix dropdown styles * Type error * Use close callback * Fix styles * Cleanup, install latest sdk * Quick muted words screen * Targets * Dir structure * Icons, list view * Move to dialog * Add removal confirmation * Swap copy * Improve checkboxees * Update matching, add tests * Moderate embeds * Create global dialogs concept again to prevent flashing * Add access from moderation screen * Highlight tags on native * Add web highlighting * Add close to web modal * Adjust close color * Rename toggles and adjust logic * Icon update * Load states * Improve regex * Improve regex * Improve regex * Revert link test * Hyphenated words * Improve matching * Enhance * Some tweaks * Muted words modal changes * Handle invalid handles, handle long tags * Remove main regex * Better test * Space/punct check drop to includes * Lowercase post text before comparison * Add better real world test case --------- Co-authored-by: Kisaragi Hiu --- .../checkThick_stroke2_corner0_rounded.svg | 1 + .../clipboard_stroke2_corner2_rounded.svg | 1 + ...gnifyingGlass2_stroke2_corner0_rounded.svg | 1 + assets/icons/mute_stroke2_corner0_rounded.svg | 1 + .../pageText_stroke2_corner0_rounded.svg | 1 + bskyweb/templates/base.html | 5 + package.json | 2 +- src/Navigation.tsx | 3 +- src/alf/atoms.ts | 4 + src/components/Dialog/index.web.tsx | 2 +- src/components/RichText.tsx | 103 +++- src/components/TagMenu/index.tsx | 279 +++++++++ src/components/TagMenu/index.web.tsx | 127 ++++ src/components/dialogs/Context.tsx | 29 + src/components/dialogs/MutedWords.tsx | 328 ++++++++++ src/components/forms/TextField.tsx | 2 +- src/components/forms/Toggle.tsx | 34 +- src/components/icons/Check.tsx | 4 + src/components/icons/Clipboard.tsx | 5 + src/components/icons/Group3.tsx | 2 +- src/components/icons/MagnifyingGlass2.tsx | 5 + src/components/icons/Mute.tsx | 5 + src/components/icons/PageText.tsx | 5 + src/components/icons/Person.tsx | 5 + .../__tests__/moderatePost_wrapped.test.ts | 578 ++++++++++++++++++ src/lib/moderatePost_wrapped.ts | 156 ++++- src/lib/moderation.ts | 7 + src/lib/routes/links.ts | 10 + src/lib/routes/types.ts | 1 + src/state/dialogs/index.tsx | 3 +- src/state/queries/preferences/const.ts | 2 + src/state/queries/preferences/index.ts | 49 +- .../com/composer/text-input/TextInput.tsx | 3 +- .../com/composer/text-input/TextInput.web.tsx | 2 + .../composer/text-input/web/TagDecorator.ts | 83 +++ src/view/com/post-thread/PostThreadItem.tsx | 6 +- src/view/com/post/Post.tsx | 2 + src/view/com/posts/FeedItem.tsx | 2 + .../com/util/forms/NativeDropdown.web.tsx | 5 + src/view/com/util/forms/PostDropdownBtn.tsx | 16 + src/view/com/util/post-embeds/QuoteEmbed.tsx | 2 + src/view/com/util/text/RichText.tsx | 66 ++ src/view/icons/index.tsx | 2 + src/view/screens/Moderation.tsx | 23 +- src/view/screens/Search/Search.tsx | 27 +- src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + web/index.html | 5 + yarn.lock | 14 + 49 files changed, 1983 insertions(+), 39 deletions(-) create mode 100644 assets/icons/checkThick_stroke2_corner0_rounded.svg create mode 100644 assets/icons/clipboard_stroke2_corner2_rounded.svg create mode 100644 assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg create mode 100644 assets/icons/mute_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pageText_stroke2_corner0_rounded.svg create mode 100644 src/components/TagMenu/index.tsx create mode 100644 src/components/TagMenu/index.web.tsx create mode 100644 src/components/dialogs/Context.tsx create mode 100644 src/components/dialogs/MutedWords.tsx create mode 100644 src/components/icons/Clipboard.tsx create mode 100644 src/components/icons/MagnifyingGlass2.tsx create mode 100644 src/components/icons/Mute.tsx create mode 100644 src/components/icons/PageText.tsx create mode 100644 src/components/icons/Person.tsx create mode 100644 src/lib/__tests__/moderatePost_wrapped.test.ts create mode 100644 src/view/com/composer/text-input/web/TagDecorator.ts diff --git a/assets/icons/checkThick_stroke2_corner0_rounded.svg b/assets/icons/checkThick_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..54af3e85 --- /dev/null +++ b/assets/icons/checkThick_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/clipboard_stroke2_corner2_rounded.svg b/assets/icons/clipboard_stroke2_corner2_rounded.svg new file mode 100644 index 00000000..f403cfb9 --- /dev/null +++ b/assets/icons/clipboard_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..2759aaf2 --- /dev/null +++ b/assets/icons/magnifyingGlass2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/mute_stroke2_corner0_rounded.svg b/assets/icons/mute_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..8ebecb39 --- /dev/null +++ b/assets/icons/mute_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pageText_stroke2_corner0_rounded.svg b/assets/icons/pageText_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..826a36cd --- /dev/null +++ b/assets/icons/pageText_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index e29e4032..50fb9a2f 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -205,6 +205,11 @@ [data-tooltip]:hover::before { display:block; } + + /* NativeDropdown component */ + .nativeDropdown-item:focus { + outline: none; + } {% include "scripts.html" %} diff --git a/package.json b/package.json index 3f007a23..3d151603 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android" }, "dependencies": { - "@atproto/api": "^0.9.5", + "@atproto/api": "^0.10.0", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 6ca4212e..dfbe816f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -497,7 +497,8 @@ const LINKING = { }, ]) } else { - return buildStateObject('Flat', name, params) + const res = buildStateObject('Flat', name, params) + return res } }, } diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 18f492d6..fff3a4d8 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,3 +1,4 @@ +import {web, native} from '#/alf/util/platform' import * as tokens from '#/alf/tokens' export const atoms = { @@ -113,6 +114,9 @@ export const atoms = { flex_wrap: { flexWrap: 'wrap', }, + flex_0: { + flex: web('0 0 auto') || (native(0) as number), + }, flex_1: { flex: 1, }, diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 79441fb5..fa29fbd6 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -188,7 +188,7 @@ export function Close() { + + ) : null} + + + + + )} + + + + ) +} diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx new file mode 100644 index 00000000..930e47a1 --- /dev/null +++ b/src/components/TagMenu/index.web.tsx @@ -0,0 +1,127 @@ +import React from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {isInvalidHandle} from '#/lib/strings/handles' +import {EventStopper} from '#/view/com/util/EventStopper' +import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown' +import {NavigationProp} from '#/lib/routes/types' +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' + +export function useTagMenuControl() {} + +export function TagMenu({ + children, + tag, + authorHandle, +}: React.PropsWithChildren<{ + tag: string + authorHandle?: string +}>) { + const sanitizedTag = tag.replace(/^#/, '') + const {_} = useLingui() + const navigation = useNavigation() + const {data: preferences} = usePreferencesQuery() + const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} = + useUpsertMutedWordsMutation() + const {mutateAsync: removeMutedWord, variables: optimisticRemove} = + useRemoveMutedWordMutation() + const isMuted = Boolean( + (preferences?.mutedWords?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + ) ?? + optimisticUpsert?.find( + m => m.value === sanitizedTag && m.targets.includes('tag'), + )) && + !(optimisticRemove?.value === sanitizedTag), + ) + + const dropdownItems = React.useMemo(() => { + return [ + { + label: _(msg`See ${tag} posts`), + onPress() { + navigation.navigate('Search', { + q: tag, + }) + }, + testID: 'tagMenuSearch', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: 'magnifying-glass', + }, + }, + authorHandle && + !isInvalidHandle(authorHandle) && { + label: _(msg`See ${tag} posts by this user`), + onPress() { + navigation.navigate({ + name: 'Search', + params: { + q: tag + (authorHandle ? ` from:${authorHandle}` : ''), + }, + }) + }, + testID: 'tagMenuSeachByUser', + icon: { + ios: { + name: 'magnifyingglass', + }, + android: '', + web: ['far', 'user'], + }, + }, + preferences && { + label: 'separator', + }, + preferences && { + label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`), + onPress() { + if (isMuted) { + removeMutedWord({value: sanitizedTag, targets: ['tag']}) + } else { + upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) + } + }, + testID: 'tagMenuMute', + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_menu_sort_alphabetically', + web: isMuted ? 'eye' : ['far', 'eye-slash'], + }, + }, + ].filter(Boolean) + }, [ + _, + authorHandle, + isMuted, + navigation, + preferences, + tag, + sanitizedTag, + upsertMutedWord, + removeMutedWord, + ]) + + return ( + + + {children} + + + ) +} diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx new file mode 100644 index 00000000..d86c90a9 --- /dev/null +++ b/src/components/dialogs/Context.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +import * as Dialog from '#/components/Dialog' + +type Control = Dialog.DialogOuterProps['control'] + +type ControlsContext = { + mutedWordsDialogControl: Control +} + +const ControlsContext = React.createContext({ + mutedWordsDialogControl: {} as Control, +}) + +export function useGlobalDialogsControlContext() { + return React.useContext(ControlsContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const mutedWordsDialogControl = Dialog.useDialogControl() + const ctx = React.useMemo( + () => ({mutedWordsDialogControl}), + [mutedWordsDialogControl], + ) + + return ( + {children} + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx new file mode 100644 index 00000000..138cc533 --- /dev/null +++ b/src/components/dialogs/MutedWords.tsx @@ -0,0 +1,328 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {AppBskyActorDefs} from '@atproto/api' + +import { + usePreferencesQuery, + useUpsertMutedWordsMutation, + useRemoveMutedWordMutation, +} from '#/state/queries/preferences' +import {isNative} from '#/platform/detection' +import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf' +import {Text} from '#/components/Typography' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText' +import {Divider} from '#/components/Divider' +import {Loader} from '#/components/Loader' +import {logger} from '#/logger' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import * as Prompt from '#/components/Prompt' + +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' + +export function MutedWordsDialog() { + const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() + return ( + + + + + ) +} + +function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const { + isLoading: isPreferencesLoading, + data: preferences, + error: preferencesError, + } = usePreferencesQuery() + const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() + const [field, setField] = React.useState('') + const [options, setOptions] = React.useState(['content']) + const [_error, setError] = React.useState('') + + const submit = React.useCallback(async () => { + const value = field.trim() + const targets = ['tag', options.includes('content') && 'content'].filter( + Boolean, + ) as AppBskyActorDefs.MutedWord['targets'] + + if (!value || !targets.length) return + + try { + await addMutedWord([{value, targets}]) + setField('') + } catch (e: any) { + logger.error(`Failed to save muted word`, {message: e.message}) + setError(e.message) + } + }, [field, options, addMutedWord, setField]) + + return ( + + + Add muted words and tags + + + + Posts can be muted based on their text, their tags, or both. + + + + + + + + + + + + + + Mute in text & tags + + + + + + + + + + + + Mute in tags only + + + + + + + + + + + + + We recommend avoiding common words that appear in many posts, since + it can result in no posts being shown. + + + + + + + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.mutedWords.length ? ( + [...preferences.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + + + {isNative && } + + + + ) +} + +function MutedWordRow({ + style, + word, +}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) { + const t = useTheme() + const {_} = useLingui() + const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation() + const control = Prompt.usePromptControl() + + const remove = React.useCallback(async () => { + control.close() + removeMutedWord(word) + }, [removeMutedWord, word, control]) + + return ( + <> + + + Are you sure? + + + + This will delete {word.value} from your muted words. You can always + add it back later. + + + + + + Nevermind + + + + + Remove + + + + + + + + {word.value} + + + + {word.targets.map(target => ( + + + {target === 'content' ? _(msg`text`) : _(msg`tag`)} + + + ))} + + + + + + ) +} + +function TargetToggle({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const ctx = Toggle.useItemContext() + const {gtMobile} = useBreakpoints() + return ( + + {children} + + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index ebf2e475..a781bdd1 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) { return ( inputRef.current?.focus(), onMouseOver: onHoverIn, diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 9369423f..140740f7 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants' import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' +import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' export type ItemState = { name: string @@ -331,15 +332,14 @@ export function createSharedToggleStyles({ export function Checkbox() { const t = useTheme() const {selected, hovered, focused, disabled, isInvalid} = useItemContext() - const {baseStyles, baseHoverStyles, indicatorStyles} = - createSharedToggleStyles({ - theme: t, - hovered, - focused, - selected, - disabled, - isInvalid, - }) + const {baseStyles, baseHoverStyles} = createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) return ( - {selected ? ( - - ) : null} + {selected ? : null} ) } diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx index 24316c78..fe9883ba 100644 --- a/src/components/icons/Check.tsx +++ b/src/components/icons/Check.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', }) + +export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z', +}) diff --git a/src/components/icons/Clipboard.tsx b/src/components/icons/Clipboard.tsx new file mode 100644 index 00000000..0135992b --- /dev/null +++ b/src/components/icons/Clipboard.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z', +}) diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx index 2bb16ba8..9e5ab889 100644 --- a/src/components/icons/Group3.tsx +++ b/src/components/icons/Group3.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z', + path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z', }) diff --git a/src/components/icons/MagnifyingGlass2.tsx b/src/components/icons/MagnifyingGlass2.tsx new file mode 100644 index 00000000..3ca40340 --- /dev/null +++ b/src/components/icons/MagnifyingGlass2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z', +}) diff --git a/src/components/icons/Mute.tsx b/src/components/icons/Mute.tsx new file mode 100644 index 00000000..00657078 --- /dev/null +++ b/src/components/icons/Mute.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z', +}) diff --git a/src/components/icons/PageText.tsx b/src/components/icons/PageText.tsx new file mode 100644 index 00000000..25fbde33 --- /dev/null +++ b/src/components/icons/PageText.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z', +}) diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx new file mode 100644 index 00000000..6d09148c --- /dev/null +++ b/src/components/icons/Person.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z', +}) diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts new file mode 100644 index 00000000..1d907963 --- /dev/null +++ b/src/lib/__tests__/moderatePost_wrapped.test.ts @@ -0,0 +1,578 @@ +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( + [{value: 'outlineTag', targets: ['tag']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + expect(match).toBe(true) + }) + + it(`match: inline tag`, () => { + const rt = new RichText({ + text: `This is a post #inlineTag`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'inlineTag', targets: ['tag']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + 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( + [{value: 'inlineTag', targets: ['content']}], + rt.text, + rt.facets, + ['outlineTag'], + ) + + expect(match).toBe(true) + }) + + it(`no match: only tag targets`, () => { + const rt = new RichText({ + text: `This is a post`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'inlineTag', targets: ['tag']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: '希', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: long muted word, short post`, () => { + const rt = new RichText({ + text: `hey`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'politics', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: exact text`, () => { + const rt = new RichText({ + text: `javascript`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'javascript', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: 'javascript', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: partial word`, () => { + const rt = new RichText({ + text: `Use your brain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'ai', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: multiline`, () => { + const rt = new RichText({ + text: `Use your\n\tbrain, Eric`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: 'brain', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: :)`, () => { + const rt = new RichText({ + text: `So happy :)`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: `:)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: 'yay!', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: yay`, () => { + const match = hasMutedWord( + [{value: 'yay', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: 'y!ppee', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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, + [], + ) + + 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( + [{value: 'S@assy', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: s@assy`, () => { + const match = hasMutedWord( + [{value: 's@assy', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: 'new york times', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`!command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot !command`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: !command`, () => { + const match = hasMutedWord( + [{value: `!command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: command`, () => { + const match = hasMutedWord( + [{value: `command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`no match: !command`, () => { + const rt = new RichText({ + text: `Idk maybe a bot command`, + }) + rt.detectFacetsWithoutResolution() + + const match = hasMutedWord( + [{value: `!command`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: `e/acc`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: acc`, () => { + const match = hasMutedWord( + [{value: `acc`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: `super-bad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: super`, () => { + const match = hasMutedWord( + [{value: `super`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: super bad`, () => { + const match = hasMutedWord( + [{value: `super bad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: superbad`, () => { + const match = hasMutedWord( + [{value: `superbad`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: `idk what this would be`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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, + [], + ) + + expect(match).toBe(false) + }) + + it(`match: idk`, () => { + // extra word + const match = hasMutedWord( + [{value: `idk`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: idkwhatthiswouldbe`, () => { + const match = hasMutedWord( + [{value: `idkwhatthiswouldbe`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(false) + }) + }) + + describe(`parentheses`, () => { + const rt = new RichText({ + text: `Post with context(iykyk)`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: context(iykyk)`, () => { + const match = hasMutedWord( + [{value: `context(iykyk)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: context`, () => { + const match = hasMutedWord( + [{value: `context`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: iykyk`, () => { + const match = hasMutedWord( + [{value: `iykyk`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: (iykyk)`, () => { + const match = hasMutedWord( + [{value: `(iykyk)`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + + describe(`🦋`, () => { + const rt = new RichText({ + text: `Post with 🦋`, + }) + rt.detectFacetsWithoutResolution() + + it(`match: 🦋`, () => { + const match = hasMutedWord( + [{value: `🦋`, targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + 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( + [{value: 'stop worrying', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + + it(`match: turtles, or how`, () => { + const match = hasMutedWord( + [{value: 'turtles, or how', targets: ['content']}], + rt.text, + rt.facets, + [], + ) + + expect(match).toBe(true) + }) + }) + }) +}) diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 2195b230..862f2de6 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -2,18 +2,122 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, moderatePost, + AppBskyActorDefs, + AppBskyFeedPost, + AppBskyRichtextFacet, + AppBskyEmbedImages, } from '@atproto/api' type ModeratePost = typeof moderatePost type Options = Parameters[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, +} + +export function hasMutedWord( + mutedWords: AppBskyActorDefs.MutedWord[], + text: string, + facets?: AppBskyRichtextFacet.Main[], + outlineTags?: string[], +) { + 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, has to use includes + if (mutedWord.length === 1 && 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[0], opts: Options, ) { - const {hiddenPosts = [], ...options} = opts + const {hiddenPosts = [], mutedWords = [], ...options} = opts const moderations = moderatePost(subject, options) if (hiddenPosts.includes(subject.uri)) { @@ -29,15 +133,65 @@ export function moderatePost_wrapped( } } + if (AppBskyFeedPost.isRecord(subject.record)) { + let muted = hasMutedWord( + mutedWords, + subject.record.text, + subject.record.facets || [], + subject.record.tags || [], + ) + + if ( + subject.record.embed && + AppBskyEmbedImages.isMain(subject.record.embed) + ) { + for (const image of subject.record.embed.images) { + muted = muted || hasMutedWord(mutedWords, image.alt, [], []) + } + } + + 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, + subject.embed.record.value.text, + subject.embed.record.value.facets, + subject.embed.record.value.tags, + ) + + if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { + for (const image of subject.embed.record.value.embed.images) { + embedHidden = + embedHidden || hasMutedWord(mutedWords, image.alt, [], []) + } + } + } } 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 bf19c208..b6ebb47a 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 538f30cd..9dfdab90 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 0fb36fa7..0ec09f61 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/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 4cafaa08..ae762bd9 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import {DialogControlProps} from '#/components/Dialog' +import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' const DialogContext = React.createContext<{ activeDialogs: React.MutableRefObject< @@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return ( - {children} + {children} ) diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 2d9d0299..25d28499 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, userAge: 13, // TODO(pwi) interests: {tags: []}, + mutedWords: [], + hiddenPosts: [], } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 632d31a1..07198de7 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -1,6 +1,10 @@ import {useMemo} from 'react' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' -import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' +import { + LabelPreference, + BskyFeedViewPreference, + AppBskyActorDefs, +} from '@atproto/api' import {track} from '#/lib/analytics/analytics' import {getAge} from '#/lib/strings/time' @@ -108,6 +112,7 @@ export function useModerationOpts() { return { ...moderationOpts, hiddenPosts, + mutedWords: prefs.data.mutedWords || [], } }, [currentAccount?.did, prefs.data, hiddenPosts]) return opts @@ -278,3 +283,45 @@ export function useUnpinFeedMutation() { }, }) } + +export function useUpsertMutedWordsMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => { + await getAgent().upsertMutedWords(mutedWords) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useUpdateMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().updateMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useRemoveMutedWordMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => { + await getAgent().removeMutedWord(mutedWord) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 17f9513b..20be585c 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl( let i = 0 return Array.from(richtext.segments()).map(segment => { - const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0]) return ( {segment.text} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 199f1f74..c62d1120 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal' import {Text} from '../../util/text/Text' import {Trans} from '@lingui/macro' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {TagDecorator} from './web/TagDecorator' export interface TextInputRef { focus: () => void @@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( () => [ Document, LinkDecorator, + TagDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts new file mode 100644 index 00000000..d820ec3f --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -0,0 +1,83 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +import {Mark} from '@tiptap/core' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + doc.descendants((node, pos) => { + if (node.isText && node.text) { + const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const textContent = node.textContent + + let match + while ((match = regex.exec(textContent))) { + const [matchedString, tag] = match + + if (tag.length > 66) continue + + const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || [] + + const from = match.index + matchedString.indexOf(tag) + const to = from + (tag.length - trailingPunc.length) + + decorations.push( + Decoration.inline(pos + from, pos + to, { + class: 'autolink', + }), + ) + } + } + }) + + return DecorationSet.create(doc, decorations) +} + +const tagDecoratorPlugin: Plugin = new Plugin({ + key: new PluginKey('link-decorator'), + + state: { + init: (_, {doc}) => getDecorations(doc), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return tagDecoratorPlugin.getState(state) + }, + }, +}) + +export const TagDecorator = Mark.create({ + name: 'tag-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [tagDecoratorPlugin] + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ebd73983..949fcfea 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({ styles.postTextLargeContainer, ]}> ) : undefined} @@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({ {richText?.text ? ( ) : undefined} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aec916ad..5fa4da84 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -184,10 +184,12 @@ function PostInner({ {richText.text ? ( ) : undefined} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 6f64de18..47a964ab 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -347,10 +347,12 @@ let PostContent = ({ {richText.text ? ( ) : undefined} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 9e9888ad..052e7ca1 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => { return ( ) : null} {embed && } diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index b6d46122..0ec3f318 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -7,6 +7,9 @@ import {lh} from 'lib/styles' import {toShortUrl} from 'lib/strings/url-helpers' import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {makeTagLink} from 'lib/routes/links' +import {TagMenu, useTagMenuControl} from '#/components/TagMenu' +import {isNative} from '#/platform/detection' const WORD_WRAP = {wordWrap: 1} @@ -82,6 +85,7 @@ export function RichText({ for (const segment of richText.segments()) { const link = segment.link const mention = segment.mention + const tag = segment.tag if ( !noLinks && mention && @@ -115,6 +119,21 @@ export function RichText({ />, ) } + } else if ( + !noLinks && + tag && + AppBskyRichtextFacet.validateTag(tag).success + ) { + els.push( + , + ) } else { els.push(segment.text) } @@ -133,3 +152,50 @@ export function RichText({ ) } + +function RichTextTag({ + text: tag, + type, + style, + lineHeightStyle, + selectable, +}: { + text: string + type?: TypographyVariant + style?: StyleProp + lineHeightStyle?: TextStyle + selectable?: boolean +}) { + const pal = usePalette('default') + const control = useTagMenuControl() + + const open = React.useCallback(() => { + control.open() + }, [control]) + + return ( + + + {isNative ? ( + + ) : ( + + {tag} + + )} + + + ) +} diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index b7bbf160..ede1e633 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown' +import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter' library.add( faAddressCard, @@ -208,4 +209,5 @@ library.add( faX, faXmark, faChevronDown, + faFilter, ) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 8f1fe75b..928766c3 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -31,6 +31,7 @@ import { useProfileUpdateMutation, } from '#/state/queries/profile' import {ScrollView} from '../com/util/Views' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' type Props = NativeStackScreenProps export function ModerationScreen({}: Props) { @@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) { const {screen, track} = useAnalytics() const {isTabletOrDesktop} = useWebMediaQueries() const {openModal} = useModalControls() + const {mutedWordsDialogControl} = useGlobalDialogsControlContext() useFocusEffect( React.useCallback(() => { @@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) { style={[styles.linkCard, pal.view]} onPress={onPressContentFiltering} accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> + accessibilityHint="" + accessibilityLabel={_(msg`Open content filtering settings`)}> Content filtering + mutedWordsDialogControl.open()} + accessibilityRole="tab" + accessibilityHint="" + accessibilityLabel={_(msg`Open muted words settings`)}> + + + + + Muted words & tags + + , ) { + const navigation = useNavigation() const theme = useTheme() const textInput = React.useRef(null) const {_} = useLingui() @@ -472,6 +474,27 @@ export function SearchScreen( React.useState(false) const [searchHistory, setSearchHistory] = React.useState([]) + /** + * The Search screen's `q` param + */ + const queryParam = props.route?.params?.q + + /** + * If `true`, this means we received new instructions from the router. This + * is handled in a effect, and used to update the value of `query` locally + * within this screen. + */ + const routeParamsMismatch = queryParam && queryParam !== query + + React.useEffect(() => { + if (queryParam && routeParamsMismatch) { + // reset immediately and let local state take over + navigation.setParams({q: ''}) + // update query for next search + setQuery(queryParam) + } + }, [queryParam, routeParamsMismatch, navigation]) + React.useEffect(() => { const loadSearchHistory = async () => { try { @@ -774,6 +797,8 @@ export function SearchScreen( )} + ) : routeParamsMismatch ? ( + ) : ( )} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 6b0cc680..d895d885 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -29,6 +29,7 @@ import {useSession} from '#/state/session' import {useCloseAnyActiveElement} from '#/state/util' import * as notifications from 'lib/notifications/notifications' import {Outlet as PortalOutlet} from '#/components/Portal' +import {MutedWordsDialog} from '#/components/dialogs/MutedWords' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -94,6 +95,7 @@ function ShellInner() { + diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 97c06550..71dccb8c 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useCloseAllActiveElements} from '#/state/util' import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {Outlet as PortalOutlet} from '#/components/Portal' +import {MutedWordsDialog} from '#/components/dialogs/MutedWords' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -40,6 +41,7 @@ function ShellInner() { + {!isDesktop && isDrawerOpen && ( diff --git a/web/index.html b/web/index.html index 992e69e0..78090591 100644 --- a/web/index.html +++ b/web/index.html @@ -209,6 +209,11 @@ [data-tooltip]:hover::before { display:block; } + + /* NativeDropdown component */ + .nativeDropdown-item:focus { + outline: none; + } diff --git a/yarn.lock b/yarn.lock index a85ea79b..3cec585b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,6 +34,20 @@ jsonpointer "^5.0.0" leven "^3.1.0" +"@atproto/api@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044" + integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ== + dependencies: + "@atproto/common-web" "^0.2.3" + "@atproto/lexicon" "^0.3.1" + "@atproto/syntax" "^0.1.5" + "@atproto/xrpc" "^0.4.1" + multiformats "^9.9.0" + tlds "^1.234.0" + typed-emitter "^2.1.0" + zod "^3.21.4" + "@atproto/api@^0.9.5": version "0.9.5" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"