diff --git a/.eslintrc.js b/.eslintrc.js index 2786b35364d..8f8383bcc7e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,8 +17,6 @@ module.exports = { 'eslint-plugin-react-compiler', ], rules: { - // Temporary until https://github.com/facebook/react-native/pull/43756 gets into a release. - 'prettier/prettier': 0, 'react/no-unescaped-entities': 0, 'react/prop-types': 0, 'react-native/no-inline-styles': 0, diff --git a/app.config.js b/app.config.js index 56142c5f198..149b949e6fa 100644 --- a/app.config.js +++ b/app.config.js @@ -93,6 +93,30 @@ module.exports = function (config) { NSPhotoLibraryUsageDescription: 'Used for profile pictures, posts, and other kinds of content', CFBundleSpokenName: 'Blue Sky', + CFBundleLocalizations: [ + 'en', + 'ca', + 'de', + 'es', + 'fi', + 'fr', + 'ga', + 'hi', + 'hu', + 'id', + 'it', + 'ja', + 'ko', + 'pl', + 'pt', + 'ru', + 'th', + 'tr', + 'uk', + 'zh_CN', + 'zh_HK', + 'zh_TW', + ], }, associatedDomains: ASSOCIATED_DOMAINS, splash: { diff --git a/assets/icons/chainLink3_stroke2_corner0_rounded.svg b/assets/icons/chainLink3_stroke2_corner0_rounded.svg new file mode 100644 index 00000000000..c1626cc6180 --- /dev/null +++ b/assets/icons/chainLink3_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 82c3fd60a04..74eacf16d44 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -9,7 +9,6 @@ import { AppBskyGraphDefs, AppBskyGraphStarterpack, AppBskyLabelerDefs, - AtUri, } from '@atproto/api' import {ComponentChildren, h} from 'preact' import {useMemo} from 'preact/hooks' @@ -437,14 +436,14 @@ function StarterPackEmbed({ // from #/lib/strings/starter-pack.ts function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) { - const rkey = new AtUri(starterPack.uri).rkey + const rkey = getRkey({uri: starterPack.uri}) return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}` } function getStarterPackHref( starterPack: AppBskyGraphDefs.StarterPackViewBasic, ) { - const rkey = new AtUri(starterPack.uri).rkey + const rkey = getRkey({uri: starterPack.uri}) const handleOrDid = starterPack.creator.handle || starterPack.creator.did return `/starter-pack/${handleOrDid}/${rkey}` } diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock index 46c8519ba12..6a86395c1ba 100644 --- a/bskyembed/yarn.lock +++ b/bskyembed/yarn.lock @@ -4209,12 +4209,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.21.4: - version "3.22.4" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" - integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== - -zod@^3.23.8: +zod@^3.21.4, zod@^3.23.8: version "3.23.8" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/package.json b/package.json index e2600173a4b..8449e1dee43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.94.0", + "version": "1.95.0", "private": true, "engines": { "node": ">=20" @@ -54,7 +54,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.13.11", + "@atproto/api": "^0.13.18", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", @@ -90,18 +90,18 @@ "@tanstack/query-async-storage-persister": "^5.25.0", "@tanstack/react-query": "^5.8.1", "@tanstack/react-query-persist-client": "^5.25.0", - "@tiptap/core": "^2.6.6", - "@tiptap/extension-document": "^2.6.6", - "@tiptap/extension-hard-break": "^2.6.6", - "@tiptap/extension-history": "^2.6.6", - "@tiptap/extension-mention": "^2.6.6", - "@tiptap/extension-paragraph": "^2.6.6", - "@tiptap/extension-placeholder": "^2.6.6", - "@tiptap/extension-text": "^2.6.6", - "@tiptap/html": "^2.6.6", - "@tiptap/pm": "^2.6.6", - "@tiptap/react": "^2.6.6", - "@tiptap/suggestion": "^2.6.6", + "@tiptap/core": "^2.9.1", + "@tiptap/extension-document": "^2.9.1", + "@tiptap/extension-hard-break": "^2.9.1", + "@tiptap/extension-history": "^2.9.1", + "@tiptap/extension-mention": "^2.9.1", + "@tiptap/extension-paragraph": "^2.9.1", + "@tiptap/extension-placeholder": "^2.9.1", + "@tiptap/extension-text": "^2.9.1", + "@tiptap/html": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "@tiptap/react": "^2.9.1", + "@tiptap/suggestion": "^2.9.1", "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", "@types/node": "^20.14.3", @@ -163,7 +163,7 @@ "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", - "react-compiler-runtime": "^19.0.0-beta-a7bf2bd-20241110", + "react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110", "react-dom": "^18.2.0", "react-image-crop": "^11.0.7", "react-keyed-flatten-children": "^3.0.0", @@ -204,7 +204,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/dev-env": "^0.3.55", + "@atproto/dev-env": "^0.3.64", "@babel/core": "^7.23.2", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", @@ -232,14 +232,14 @@ "babel-jest": "^29.7.0", "babel-plugin-macros": "^3.1.0", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "^19.0.0-beta-a7bf2bd-20241110", + "babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", "babel-preset-expo": "^10.0.0", "eslint": "^8.19.0", "eslint-plugin-bsky-internal": "link:./eslint", "eslint-plugin-ft-flow": "^2.0.3", "eslint-plugin-lingui": "^0.2.0", "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-compiler": "^19.0.0-beta-a7bf2bd-20241110", + "eslint-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110", "eslint-plugin-react-native-a11y": "^3.3.0", "eslint-plugin-simple-import-sort": "^12.0.0", "file-loader": "6.2.0", @@ -256,17 +256,16 @@ "react-test-renderer": "18.2.0", "svgo": "^3.3.2", "ts-node": "^10.9.1", - "typescript": "^5.5.4", + "typescript": "^5.7.2", "webpack-bundle-analyzer": "^4.10.1" }, "resolutions": { + "@radix-ui/react-focus-scope": "1.1.0", + "@react-native/babel-preset": "0.74.1", "@types/react": "^18", - "**/zeed-dom": "0.10.9", - "**/zod": "3.23.8", "**/expo-constants": "16.0.1", "**/expo-device": "6.0.2", - "@react-native/babel-preset": "0.74.1", - "@radix-ui/react-focus-scope": "1.1.0" + "**/zod": "3.23.8" }, "jest": { "preset": "jest-expo/ios", diff --git a/patches/react-native-svg+15.3.0.patch b/patches/react-native-svg+15.3.0.patch new file mode 100644 index 00000000000..54540023f1e --- /dev/null +++ b/patches/react-native-svg+15.3.0.patch @@ -0,0 +1,57 @@ +diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/PathView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/PathView.java +index 06829bd..1b15818 100644 +--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/PathView.java ++++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/PathView.java +@@ -14,17 +14,33 @@ import android.graphics.Paint; + import android.graphics.Path; + import com.facebook.react.bridge.ReactContext; + ++import java.util.ArrayList; ++import java.util.HashMap; ++ ++class ParsedPath { ++ final Path path; ++ final ArrayList elements; ++ ++ ParsedPath(Path path, ArrayList elements) { ++ this.path = path; ++ this.elements = elements; ++ } ++} ++ + @SuppressLint("ViewConstructor") + class PathView extends RenderableView { + private Path mPath; + ++ // This grows forever but for our use case (static icons) it's ok. ++ private static final HashMap sPathCache = new HashMap<>(); ++ + public PathView(ReactContext reactContext) { + super(reactContext); + PathParser.mScale = mScale; + mPath = new Path(); + } + +- public void setD(String d) { ++ void setDByParsing(String d) { + mPath = PathParser.parse(d); + elements = PathParser.elements; + for (PathElement elem : elements) { +@@ -33,6 +49,17 @@ class PathView extends RenderableView { + point.y *= mScale; + } + } ++ } ++ ++ public void setD(String d) { ++ ParsedPath cached = sPathCache.get(d); ++ if (cached != null) { ++ mPath = cached.path; ++ elements = cached.elements; ++ } else { ++ setDByParsing(d); ++ sPathCache.put(d, new ParsedPath(mPath, elements)); ++ } + invalidate(); + } + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 1806df92eb8..cc815ef70b5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -32,7 +32,7 @@ import { import {RouteParams, State} from '#/lib/routes/types' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' -import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useSession} from '#/state/session' @@ -453,7 +453,6 @@ function HomeTabNavigator() { return ( { 24 + */ +export function leading< + Size extends {fontSize?: number}, + Leading extends {lineHeight?: number}, +>(textSize: Size, leading: Leading) { + const size = textSize?.fontSize || atoms.text_md.fontSize + const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight + return Math.round(size * lineHeight) +} + +/** + * Ensures that `lineHeight` defaults to a relative value of `1`, or applies + * other relative leading atoms. + * + * If the `lineHeight` value is > 2, we assume it's an absolute value and + * returns it as-is. + */ +export function normalizeTextStyles( + styles: StyleProp, + { + fontScale, + fontFamily, + }: { + fontScale: number + fontFamily: Alf['fonts']['family'] + } & Pick, +) { + const s = flatten(styles) + // should always be defined on these components + s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale + + if (s?.lineHeight) { + if (s.lineHeight !== 0 && s.lineHeight <= 2) { + s.lineHeight = Math.round(s.fontSize * s.lineHeight) + } + } else if (!isNative) { + s.lineHeight = s.fontSize + } + + applyFonts(s, fontFamily) + + return s +} + +export type StringChild = string | (string | null)[] +export type TextProps = RNTextProps & { + /** + * Lets the user select text, to use the native copy and paste functionality. + */ + selectable?: boolean + /** + * Provides `data-*` attributes to the underlying `UITextView` component on + * web only. + */ + dataSet?: Record + /** + * Appears as a small tooltip on web hover. + */ + title?: string + /** + * Whether the children could possibly contain emoji. + */ + emoji?: boolean +} + +const EMOJI = createEmojiRegex() + +export function childHasEmoji(children: React.ReactNode) { + let hasEmoji = false + Children.forEach(children, child => { + if (typeof child === 'string' && createEmojiRegex().test(child)) { + hasEmoji = true + } + }) + return hasEmoji +} + +export function renderChildrenWithEmoji( + children: React.ReactNode, + props: Omit = {}, + emoji: boolean, +) { + if (!isIOS || !emoji) { + return children + } + return Children.map(children, child => { + if (typeof child !== 'string') return child + + const emojis = child.match(EMOJI) + + if (emojis === null) { + return child + } + + return child.split(EMOJI).map((stringPart, index) => [ + stringPart, + emojis[index] ? ( + + {emojis[index]} + + ) : null, + ]) + }) +} + +const SINGLE_EMOJI_RE = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u +export function isOnlyEmoji(text: string) { + return text.length <= 15 && SINGLE_EMOJI_RE.test(text) +} diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 41a39ffda55..6b92eee3e0d 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -7,7 +7,6 @@ import { View, ViewStyle, } from 'react-native' -import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {DismissableLayer} from '@radix-ui/react-dismissable-layer' @@ -42,7 +41,6 @@ export function Outer({ onClose, }: React.PropsWithChildren) { const {_} = useLingui() - const t = useTheme() const {gtMobile} = useBreakpoints() const [isOpen, setIsOpen] = React.useState(false) const {setDialogIsOpen} = useDialogStateControlContext() @@ -118,16 +116,7 @@ export function Outer({ gtMobile ? a.p_lg : a.p_md, {overflowY: 'auto'}, ]}> - - + - true} onTouchEnd={stopPropagation} - entering={FadeInDown.duration(100)} - // exiting={FadeOut.duration(100)} style={flatten([ a.relative, a.rounded_md, @@ -188,6 +175,8 @@ export function Inner({ shadowColor: t.palette.black, shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowRadius: 30, + // @ts-ignore web only + animation: 'fadeIn ease-out 0.1s', }, flatten(style), ])}> @@ -201,7 +190,7 @@ export function Inner({ {children} - + ) } @@ -268,3 +257,25 @@ export function Close() { export function Handle() { return null } + +function Backdrop() { + const t = useTheme() + return ( + + + + ) +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 054a543c198..ef31ea0c537 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -223,7 +223,7 @@ export function Link({ {...web({ hrefAttrs: { target: download ? undefined : isExternal ? 'blank' : undefined, - rel: isExternal ? 'noopener noreferrer' : undefined, + rel: isExternal ? 'noopener' : undefined, download, }, dataSet: { @@ -274,11 +274,6 @@ export function InlineLinkText({ onOut: onHoverOut, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const { - state: pressed, - onIn: onPressIn, - onOut: onPressOut, - } = useInteractionState() const flattenedStyle = flatten(style) || {} return ( @@ -289,19 +284,20 @@ export function InlineLinkText({ {...rest} style={[ {color: t.palette.primary_500}, - (hovered || focused || pressed) && + (hovered || focused) && !disableUnderline && { - ...web({outline: 0}), - textDecorationLine: 'underline', - textDecorationColor: flattenedStyle.color ?? t.palette.primary_500, + ...web({ + outline: 0, + textDecorationLine: 'underline', + textDecorationColor: + flattenedStyle.color ?? t.palette.primary_500, + }), }, flattenedStyle, ]} role="link" onPress={download ? undefined : onPress} onLongPress={onLongPress} - onPressIn={onPressIn} - onPressOut={onPressOut} onFocus={onFocus} onBlur={onBlur} onMouseEnter={onHoverIn} @@ -311,7 +307,7 @@ export function InlineLinkText({ {...web({ hrefAttrs: { target: download ? undefined : isExternal ? 'blank' : undefined, - rel: isExternal ? 'noopener noreferrer' : undefined, + rel: isExternal ? 'noopener' : undefined, download, }, dataSet: { diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 4cda42fdbe5..3e58ced9029 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -302,8 +302,8 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { const animationStyle = { animation: currentState.stage === 'hiding' - ? `avatarHoverFadeOut ${HIDE_DURATION}ms both` - : `avatarHoverFadeIn ${SHOW_DURATION}ms both`, + ? `fadeOut ${HIDE_DURATION}ms both` + : `fadeIn ${SHOW_DURATION}ms both`, } return ( diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 8f6358dd5a7..6d7e50e4809 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -9,6 +9,7 @@ import {NavigationProp} from '#/lib/routes/types' import {toShortUrl} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf' +import {isOnlyEmoji} from '#/alf/typography' import {useInteractionState} from '#/components/hooks/useInteractionState' import {InlineLinkText, LinkProps} from '#/components/Link' import {ProfileHoverCard} from '#/components/ProfileHoverCard' @@ -53,7 +54,6 @@ export function RichText({ const plainStyles = [a.leading_snug, flattenedStyle] const interactiveStyles = [ a.leading_snug, - a.pointer_events_auto, flatten(interactiveStyle), flattenedStyle, ] @@ -151,17 +151,14 @@ export function RichText({ />, ) } else { - els.push( - - {segment.text} - , - ) + els.push(segment.text) } key++ } return ( () const navigateToPage = React.useCallback(() => { @@ -228,8 +220,6 @@ function RichTextTag({ accessibilityRole: isNative ? 'button' : undefined, onPress: navigateToPage, onLongPress: openDialog, - onPressIn: onPressIn, - onPressOut: onPressOut, })} {...web({ onMouseEnter: onHoverIn, @@ -243,10 +233,12 @@ function RichTextTag({ cursor: 'pointer', }), {color: t.palette.primary_500}, - (hovered || focused || pressed) && { - ...web({outline: 0}), - textDecorationLine: 'underline', - textDecorationColor: t.palette.primary_500, + (hovered || focused) && { + ...web({ + outline: 0, + textDecorationLine: 'underline', + textDecorationColor: t.palette.primary_500, + }), }, style, ]}> @@ -256,10 +248,3 @@ function RichTextTag({ ) } - -export function isOnlyEmoji(text: string) { - return ( - text.length <= 15 && - /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text) - ) -} diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index ecd6225bb06..c1c10c76b9d 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef( ref, ) { const t = useTheme() - const bottomBarOffset = useBottomBarOffset(300) + const bottomBarOffset = useBottomBarOffset(headerHeight) const initialNumToRender = useInitialNumToRender() const {currentAccount} = useSession() const {data, refetch, isError} = useAllListMembersQuery(listUri) diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 69e07327139..3e202cb8f2e 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,140 +1,15 @@ -import React from 'react' -import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native' import {UITextView} from 'react-native-uitextview' -import createEmojiRegex from 'emoji-regex' import {logger} from '#/logger' -import {isIOS, isNative} from '#/platform/detection' -import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf' +import {atoms, flatten, useAlf, useTheme, web} from '#/alf' +import { + childHasEmoji, + normalizeTextStyles, + renderChildrenWithEmoji, + TextProps, +} from '#/alf/typography' import {IS_DEV} from '#/env' - -export type StringChild = string | (string | null)[] - -export type TextProps = Omit & { - /** - * Lets the user select text, to use the native copy and paste functionality. - */ - selectable?: boolean - /** - * Provides `data-*` attributes to the underlying `UITextView` component on - * web only. - */ - dataSet?: Record - /** - * Appears as a small tooltip on web hover. - */ - title?: string -} & ( - | { - emoji?: true - children: StringChild - } - | { - emoji?: false - children: RNTextProps['children'] - } - ) - -const EMOJI = createEmojiRegex() - -export function childHasEmoji(children: React.ReactNode) { - return (Array.isArray(children) ? children : [children]).some( - child => typeof child === 'string' && createEmojiRegex().test(child), - ) -} - -export function childIsString( - children: React.ReactNode, -): children is StringChild { - return ( - typeof children === 'string' || - (Array.isArray(children) && - children.every(child => typeof child === 'string' || child === null)) - ) -} - -export function renderChildrenWithEmoji( - children: StringChild, - props: Omit = {}, -) { - const normalized = Array.isArray(children) ? children : [children] - - return ( - - {normalized.map(child => { - if (typeof child !== 'string') return child - - const emojis = child.match(EMOJI) - - if (emojis === null) { - return child - } - - return child.split(EMOJI).map((stringPart, index) => ( - - {stringPart} - {emojis[index] ? ( - - {emojis[index]} - - ) : null} - - )) - })} - - ) -} - -/** - * Util to calculate lineHeight from a text size atom and a leading atom - * - * Example: - * `leading(atoms.text_md, atoms.leading_normal)` // => 24 - */ -export function leading< - Size extends {fontSize?: number}, - Leading extends {lineHeight?: number}, ->(textSize: Size, leading: Leading) { - const size = textSize?.fontSize || atoms.text_md.fontSize - const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight - return Math.round(size * lineHeight) -} - -/** - * Ensures that `lineHeight` defaults to a relative value of `1`, or applies - * other relative leading atoms. - * - * If the `lineHeight` value is > 2, we assume it's an absolute value and - * returns it as-is. - */ -export function normalizeTextStyles( - styles: StyleProp, - { - fontScale, - fontFamily, - }: { - fontScale: number - fontFamily: Alf['fonts']['family'] - } & Pick, -) { - const s = flatten(styles) - // should always be defined on these components - s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale - - if (s?.lineHeight) { - if (s.lineHeight !== 0 && s.lineHeight <= 2) { - s.lineHeight = Math.round(s.fontSize * s.lineHeight) - } - } else if (!isNative) { - s.lineHeight = s.fontSize - } - - applyFonts(s, fontFamily) - - return s -} +export type {TextProps} /** * Our main text component. Use this most of the time. @@ -162,10 +37,6 @@ export function Text({ `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`, ) } - - if (emoji && !childIsString(children)) { - logger.error('Text: when , children can only be strings.') - } } const shared = { @@ -178,12 +49,12 @@ export function Text({ return ( - {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + {renderChildrenWithEmoji(children, shared, emoji ?? false)} ) } -export function createHeadingElement({level}: {level: number}) { +function createHeadingElement({level}: {level: number}) { return function HeadingElement({style, ...rest}: TextProps) { const attr = web({ diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index 81590e3c98e..8536001da4e 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -256,6 +256,9 @@ export function PostInteractionSettingsForm({ } else { newSelected.splice(i, 1) } + if (newSelected.length === 0) { + newSelected.push({type: 'everybody'}) + } onChangeThreadgateAllowUISettings(newSelected) } diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 52220e2cacc..79f0997fd6d 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -19,10 +19,11 @@ import {ConvoItem} from '#/state/messages/convo/types' import {useSession} from '#/state/session' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {atoms as a, useTheme} from '#/alf' +import {isOnlyEmoji} from '#/alf/typography' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' -import {isOnlyEmoji, RichText} from '../RichText' +import {RichText} from '../RichText' import {DateDivider} from './DateDivider' import {MessageItemEmbed} from './MessageItemEmbed' import {localDateString} from './util' diff --git a/src/components/icons/ChainLink.tsx b/src/components/icons/ChainLink.tsx new file mode 100644 index 00000000000..ba0b417a9a2 --- /dev/null +++ b/src/components/icons/ChainLink.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ChainLink3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z', +}) diff --git a/src/components/video/PlayButtonIcon.tsx b/src/components/video/PlayButtonIcon.tsx index 84a45076778..801cad9b923 100644 --- a/src/components/video/PlayButtonIcon.tsx +++ b/src/components/video/PlayButtonIcon.tsx @@ -9,39 +9,23 @@ export function PlayButtonIcon({size = 32}: {size?: number}) { const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25 return ( - + <> - - + + ) } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index cd9183c953f..ee066d9197e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -154,6 +154,7 @@ export const SUPPORTED_MIME_TYPES = [ 'video/mpeg', 'video/webm', 'video/quicktime', + 'image/gif', ] as const export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number] diff --git a/src/lib/hooks/useOpenLink.ts b/src/lib/hooks/useOpenLink.ts index 5b75695b8e3..727821670ff 100644 --- a/src/lib/hooks/useOpenLink.ts +++ b/src/lib/hooks/useOpenLink.ts @@ -4,12 +4,14 @@ import * as WebBrowser from 'expo-web-browser' import { createBskyAppAbsoluteUrl, + isBskyAppUrl, isBskyRSSUrl, isRelativeUrl, } from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useInAppBrowser} from '#/state/preferences/in-app-browser' +import {useOptOutOfUtm} from '#/state/preferences/opt-out-of-utm' import {useTheme} from '#/alf' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' @@ -18,6 +20,7 @@ export function useOpenLink() { const enabled = useInAppBrowser() const t = useTheme() const sheetWrapper = useSheetWrapper() + const optOutOfUtm = useOptOutOfUtm() const openLink = useCallback( async (url: string, override?: boolean) => { @@ -26,6 +29,9 @@ export function useOpenLink() { } if (isNative && !url.startsWith('mailto:')) { + if (!optOutOfUtm && !isBskyAppUrl(url) && url.startsWith('http')) { + url = addUtmSource(url) + } if (override === undefined && enabled === undefined) { openModal({ name: 'in-app-browser-consent', @@ -47,8 +53,24 @@ export function useOpenLink() { } Linking.openURL(url) }, - [enabled, openModal, t, sheetWrapper], + [enabled, openModal, t, sheetWrapper, optOutOfUtm], ) return openLink } + +function addUtmSource(url: string): string { + let parsedUrl + try { + parsedUrl = new URL(url) + } catch (e) { + return url + } + if (!parsedUrl.searchParams.has('utm_source')) { + parsedUrl.searchParams.set('utm_source', 'bluesky') + if (!parsedUrl.searchParams.has('utm_medium')) { + parsedUrl.searchParams.set('utm_medium', 'social') + } + } + return parsedUrl.toString() +} diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts index 87b422c2c91..b80e0a4a1eb 100644 --- a/src/lib/media/video/util.ts +++ b/src/lib/media/video/util.ts @@ -32,6 +32,8 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { return 'mpeg' case 'video/quicktime': return 'mov' + case 'image/gif': + return 'gif' default: throw new Error(`Unsupported mime type: ${mimeType}`) } @@ -47,6 +49,8 @@ export function extToMime(ext: string) { return 'video/mpeg' case 'mov': return 'video/quicktime' + case 'gif': + return 'image/gif' default: throw new Error(`Unsupported file extension: ${ext}`) } diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 715e09be76a..3cec5d5b299 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -2,4 +2,3 @@ export type Gate = // Keep this alphabetic please. | 'debug_show_feedcontext' // DISABLED DUE TO EME | 'post_feed_lang_window' // DISABLED DUE TO EME - | 'suggested_feeds_interstitial' diff --git a/src/locale/deviceLocales.ts b/src/locale/deviceLocales.ts index 9e19e372b89..1abaa20f699 100644 --- a/src/locale/deviceLocales.ts +++ b/src/locale/deviceLocales.ts @@ -13,6 +13,12 @@ type LocalWithLanguageCode = Locale & { * * {@link https://github.com/bluesky-social/social-app/pull/4461} * {@link https://xml.coverpages.org/iso639a.html} + * + * Convert Chinese language tags for Native. + * + * {@link https://datatracker.ietf.org/doc/html/rfc5646#appendix-A} + * {@link https://developer.apple.com/documentation/packagedescription/languagetag} + * {@link https://gist.github.com/amake/0ac7724681ac1c178c6f95a5b09f03ce#new-locales-vs-old-locales-chinese} */ export function getLocales() { const locales = defaultGetLocales?.() ?? [] @@ -32,10 +38,25 @@ export function getLocales() { // yiddish locale.languageCode = 'yi' } + } - // @ts-ignore checked above - output.push(locale) + if (typeof locale.languageTag === 'string') { + if (locale.languageTag.startsWith('zh-Hans')) { + // Simplified Chinese to zh-CN + locale.languageTag = 'zh-CN' + } + if (locale.languageTag.startsWith('zh-Hant')) { + // Traditional Chinese to zh-TW + locale.languageTag = 'zh-TW' + } + if (locale.languageTag.startsWith('yue')) { + // Cantonese (Yue) to zh-HK + locale.languageTag = 'zh-HK' + } } + + // @ts-ignore checked above + output.push(locale) } return output diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 7a841f383d4..be9b939503a 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -224,7 +224,7 @@ export async function dynamicActivate(locale: AppLanguage) { } } -export async function useLocaleLanguage() { +export function useLocaleLanguage() { const {appLanguage} = useLanguagePrefs() useEffect(() => { dynamicActivate(sanitizeAppLanguageSetting(appLanguage)) diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index b15cd24921f..72e0382a937 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -38,7 +38,7 @@ export function MessageInput({ children?: React.ReactNode openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { - const {isTabletOrDesktop} = useWebMediaQueries() + const {isMobile} = useWebMediaQueries() const {_} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() @@ -212,7 +212,7 @@ export function MessageInput({ onChange={onChange} // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message // in these cases. - onKeyDown={isTouchDevice && isTabletOrDesktop ? undefined : onKeyDown} + onKeyDown={isTouchDevice && isMobile ? undefined : onKeyDown} /> {profile.viewer?.followedBy && !blockHide ? ( diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index b3fb8c174fc..27448ba9af7 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -9,9 +9,16 @@ import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' +import { + useOptOutOfUtm, + useSetOptOutOfUtm, +} from '#/state/preferences/opt-out-of-utm' import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' import * as Toggle from '#/components/forms/Toggle' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' +import {ChainLink3_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons/Hashtag' import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' @@ -29,6 +36,8 @@ export function ContentAndMediaSettingsScreen({}: Props) { const setAutoplayDisabledPref = useSetAutoplayDisabled() const inAppBrowserPref = useInAppBrowser() const setUseInAppBrowser = useSetInAppBrowser() + const optOutOfUtm = useOptOutOfUtm() + const setOptOutOfUtm = useSetOptOutOfUtm() return ( @@ -68,6 +77,19 @@ export function ContentAndMediaSettingsScreen({}: Props) { + setAutoplayDisabledPref(!value)}> + + + + Autoplay videos and GIFs + + + + {isNative && ( )} - setAutoplayDisabledPref(!value)}> + {isNative && } + {isNative && ( + setOptOutOfUtm(!value)}> + + + + Send Bluesky referrer + + + + + )} + {isNative && ( - - - Autoplay videos and GIFs - - + + + Helps external sites estimate traffic from Bluesky. + + - + )} diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index c27cea7dec3..d29daa58b82 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -56,6 +56,12 @@ export function ThreadPreferencesScreen({}: Props) { values={sortReplies ? [sortReplies] : []} onChange={values => setThreadViewPrefs({sort: values[0]})}> + + + + Hot replies first + + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 80401794968..85a6bf8e20c 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -124,6 +124,7 @@ const schema = z.object({ subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), + optOutOfUtm: z.boolean().optional(), }) export type Schema = z.infer @@ -169,6 +170,7 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, subtitlesEnabled: true, + optOutOfUtm: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c7eaf272610..43a08926e8a 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' +import {Provider as OutOutOfUtmProvider} from './opt-out-of-utm' import {Provider as SubtitlesProvider} from './subtitles' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' @@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + {children} + diff --git a/src/state/preferences/opt-out-of-utm.tsx b/src/state/preferences/opt-out-of-utm.tsx new file mode 100644 index 00000000000..40144c8db51 --- /dev/null +++ b/src/state/preferences/opt-out-of-utm.tsx @@ -0,0 +1,42 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean +type SetContext = (v: boolean) => void + +const stateContext = React.createContext( + Boolean(persisted.defaults.optOutOfUtm), +) +const setContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState( + Boolean(persisted.get('optOutOfUtm')), + ) + + const setStateWrapped = React.useCallback( + (optOutOfUtm: persisted.Schema['optOutOfUtm']) => { + setState(Boolean(optOutOfUtm)) + persisted.write('optOutOfUtm', optOutOfUtm) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('optOutOfUtm', nextOptOutOfUtm => { + setState(Boolean(nextOptOutOfUtm)) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export const useOptOutOfUtm = () => React.useContext(stateContext) +export const useSetOptOutOfUtm = () => React.useContext(setContext) diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index b2e9dcd4c98..016d8893b0f 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -602,7 +602,7 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { } if (!somePostsPassModeration) { - throw new Error(KnownError.FeedNSFPublic) + throw new Error(KnownError.FeedSignedInOnly) } } diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 93e3a5c3bc3..4784a9d7598 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -237,7 +237,11 @@ export function sortThread( } } - if (opts.sort === 'oldest') { + if (opts.sort === 'hotness') { + const aHotness = getHotness(a.post) + const bHotness = getHotness(b.post) + return bHotness - aHotness + } else if (opts.sort === 'oldest') { return a.post.indexedAt.localeCompare(b.post.indexedAt) } else if (opts.sort === 'newest') { return b.post.indexedAt.localeCompare(a.post.indexedAt) @@ -269,6 +273,21 @@ export function sortThread( // internal methods // = +// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html +// We want to give recent comments a real chance (and not bury them deep below the fold) +// while also surfacing well-liked comments from the past. In the future, we can explore +// something more sophisticated, but we don't have much data on the client right now. +function getHotness(post: AppBskyFeedDefs.PostView) { + const hoursAgo = + (new Date().getTime() - new Date(post.indexedAt).getTime()) / + (1000 * 60 * 60) + const likeCount = post.likeCount ?? 0 + const likeOrder = Math.log(3 + likeCount) + const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount)) + const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent) + return likeOrder / timePenalty +} + function responseToThreadNodes( node: ThreadViewNode, depth = 0, diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index e07f40ec52a..549f7ce2918 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -15,7 +15,7 @@ export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs } export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = { - sort: 'newest', + sort: 'hotness', prioritizeFollowedUsers: true, lab_treeViewEnabled: false, } diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts index 928bb90da88..8f523fcf224 100644 --- a/src/state/queries/preferences/types.ts +++ b/src/state/queries/preferences/types.ts @@ -22,6 +22,6 @@ export type ThreadViewPreferences = Pick< BskyThreadViewPreference, 'prioritizeFollowedUsers' > & { - sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string + sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string lab_treeViewEnabled?: boolean } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 84f209d95dd..63c405788d0 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -409,7 +409,7 @@ function useProfileUnmuteMutation() { } export function useProfileBlockMutationQueue( - profile: Shadow, + profile: Shadow, ) { const queryClient = useQueryClient() const did = profile.did diff --git a/src/style.css b/src/style.css index 6f908c288bd..f6e9a6e6cad 100644 --- a/src/style.css +++ b/src/style.css @@ -184,7 +184,7 @@ input:focus { animation: rotate 500ms linear infinite; } -@keyframes avatarHoverFadeIn { +@keyframes fadeIn { from { opacity: 0; } @@ -193,7 +193,7 @@ input:focus { } } -@keyframes avatarHoverFadeOut { +@keyframes fadeOut { from { opacity: 1; } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5d9f607661a..e4b09cf0f8b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -56,13 +56,18 @@ import {useQueryClient} from '@tanstack/react-query' import * as apilib from '#/lib/api/index' import {EmbeddingDisabledError} from '#/lib/api/resolve' import {until} from '#/lib/async/until' -import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' +import { + MAX_GRAPHEME_LENGTH, + SUPPORTED_MIME_TYPES, + SupportedMimeTypes, +} from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {mimeToExt} from '#/lib/media/video/util' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {colors, s} from '#/lib/styles' @@ -130,6 +135,7 @@ import { ThreadDraft, } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' type CancelRef = { @@ -746,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/')) { - onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) + if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if (isNative) return // web only + const [mimeType] = uri.slice('data:'.length).split(';') + if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { + Toast.show(_(msg`Unsupported video type`), 'xmark') + return + } + const name = `pasted.${mimeToExt(mimeType)}` + const file = await fetch(uri) + .then(res => res.blob()) + .then(blob => new File([blob], name, {type: mimeType})) + onSelectVideo(post.id, await getVideoMetadata(file)) } else { const res = await pasteImage(uri) onImageAdd([res]) } }, - [post.id, onSelectVideo, onImageAdd], + [post.id, onSelectVideo, onImageAdd, _], ) return ( @@ -1009,17 +1025,6 @@ function ComposerEmbeds({ asset={video.asset} video={video.video} isActivePost={isActivePost} - setDimensions={(width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: video.abortController.signal, - }, - }) - }} clear={clearVideo} /> ) : null)} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 8814a7e61c1..7ce4a0cf829 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -36,12 +36,6 @@ export type VideoAction = signal: AbortSignal } | {type: 'update_progress'; progress: number; signal: AbortSignal} - | { - type: 'update_dimensions' - width: number - height: number - signal: AbortSignal - } | { type: 'update_alt_text' altText: string @@ -185,13 +179,6 @@ export function videoReducer( progress: action.progress, } } - } else if (action.type === 'update_dimensions') { - if (state.asset) { - return { - ...state, - asset: {...state.asset, width: action.width, height: action.height}, - } - } } else if (action.type === 'update_alt_text') { return { ...state, diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 10cf1a931b5..96cecb37cb9 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -31,7 +31,7 @@ import { suggestLinkCardUri, } from '#/view/com/composer/text-input/text-input-util' import {atoms as a, useAlf} from '#/alf' -import {normalizeTextStyles} from '#/components/Typography' +import {normalizeTextStyles} from '#/alf/typography' import {Autocomplete} from './mobile/Autocomplete' export interface TextInputRef { diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index fa742d25870..8ec4fefa822 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,6 +11,7 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' +import {Fragment, Node, Slice} from '@tiptap/pm/model' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' @@ -23,8 +24,8 @@ import { } from '#/view/com/composer/text-input/text-input-util' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' +import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' -import {normalizeTextStyles} from '#/components/Typography' import {Text} from '../../util/text/Text' import {createSuggestion} from './web/Autocomplete' import {Emoji} from './web/EmojiPicker.web' @@ -166,6 +167,11 @@ export const TextInput = React.forwardRef(function TextInputImpl( const editor = useEditor( { extensions, + coreExtensionOptions: { + clipboardTextSerializer: { + blockSeparator: '\n', + }, + }, onFocus() { onFocus?.() }, @@ -173,6 +179,20 @@ export const TextInput = React.forwardRef(function TextInputImpl( attributes: { class: modeClass, }, + clipboardTextParser: (text, context) => { + const blocks = text.split(/(?:\r\n?|\n)/) + const nodes: Node[] = blocks.map(line => { + return Node.fromJSON( + context.doc.type.schema, + line.length > 0 + ? {type: 'paragraph', content: [{type: 'text', text: line}]} + : {type: 'paragraph', content: []}, + ) + }) + + const fragment = Fragment.fromArray(nodes) + return Slice.maxOpen(fragment) + }, handlePaste: (view, event) => { const clipboardData = event.clipboardData let preventDefault = false @@ -205,6 +225,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( autofocus: 'end', editable: true, injectCSS: true, + shouldRerenderOnTransaction: false, onCreate({editor: editorProp}) { // HACK // the 'enter' animation sometimes causes autofocus to fail @@ -297,15 +318,9 @@ export const TextInput = React.forwardRef(function TextInputImpl( style.lineHeight = style.lineHeight ? ((style.lineHeight + 'px') as unknown as number) : undefined + style.minHeight = webForceMinHeight ? 140 : undefined return style - }, [t, fonts]) - - React.useLayoutEffect(() => { - let node = editor?.view.dom - if (node) { - node.style.minHeight = webForceMinHeight ? '140px' : '' - } - }, [editor, webForceMinHeight]) + }, [t, fonts, webForceMinHeight]) return ( <> diff --git a/src/view/com/composer/text-input/hooks/useGrapheme.tsx b/src/view/com/composer/text-input/hooks/useGrapheme.tsx index 01b5b969899..aa375ff4702 100644 --- a/src/view/com/composer/text-input/hooks/useGrapheme.tsx +++ b/src/view/com/composer/text-input/hooks/useGrapheme.tsx @@ -13,7 +13,7 @@ export const useGrapheme = () => { if (graphemes.length > length) { remainingCharacters = 0 - name = `${graphemes.slice(0, length).join('')}...` + name = `${graphemes.slice(0, length).join('')}…` } else { remainingCharacters = length - graphemes.length name = graphemes.join('') diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index f1b59413645..0fda6843b4b 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,7 +1,5 @@ -import {useRef} from 'react' import {View} from 'react-native' import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated' -import {AppBskyActorDefs} from '@atproto/api' import {Trans} from '@lingui/macro' import {PressableScale} from '#/lib/custom-animations/PressableScale' @@ -11,7 +9,6 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' -import {useGrapheme} from '../hooks/useGrapheme' export function Autocomplete({ prefix, @@ -22,15 +19,11 @@ export function Autocomplete({ }) { const t = useTheme() - const {getGraphemeString} = useGrapheme() const isActive = !!prefix - const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) - const suggestionsRef = useRef< - AppBskyActorDefs.ProfileViewBasic[] | undefined - >(undefined) - if (suggestions) { - suggestionsRef.current = suggestions - } + const {data: suggestions, isFetching} = useActorAutocompleteQuery( + prefix, + true, + ) if (!isActive) return null @@ -46,26 +39,8 @@ export function Autocomplete({ t.atoms.border_contrast_high, {marginLeft: -62}, ]}> - {suggestionsRef.current?.length ? ( - suggestionsRef.current.slice(0, 5).map((item, index, arr) => { - // Eventually use an average length - const MAX_CHARS = 40 - const MAX_HANDLE_CHARS = 20 - - // Using this approach because styling is not respecting - // bounding box wrapping (before converting to ellipsis) - const {name: displayHandle, remainingCharacters} = getGraphemeString( - item.handle, - MAX_HANDLE_CHARS, - ) - - const {name: displayName} = getGraphemeString( - item.displayName || item.handle, - MAX_CHARS - - MAX_HANDLE_CHARS + - (remainingCharacters > 0 ? remainingCharacters : 0), - ) - + {suggestions?.length ? ( + suggestions.slice(0, 5).map((item, index, arr) => { return ( + {sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + )} + + - {sanitizeDisplayName(displayName)} + {sanitizeHandle(item.handle, '@')} - - {sanitizeHandle(displayHandle, '@')} - ) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 0599ddb395f..f40c2ee8d9b 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -10,6 +10,8 @@ import { import tippy, {Instance as TippyInstance} from 'tippy.js' import {usePalette} from '#/lib/hooks/usePalette' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' @@ -148,7 +150,9 @@ const MentionList = forwardRef( {items.length > 0 ? ( items.map((item, index) => { const {name: displayName} = getGraphemeString( - item.displayName ?? item.handle, + sanitizeDisplayName( + item.displayName || sanitizeHandle(item.handle), + ), 30, // Heuristic value; can be modified ) const isSelected = selectedIndex === index @@ -181,7 +185,7 @@ const MentionList = forwardRef( - @{item.handle} + {sanitizeHandle(item.handle, '@')} ) diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index ac9ae521c35..1b052ccdd3e 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -1,11 +1,6 @@ import {useCallback} from 'react' import {Keyboard} from 'react-native' -import { - ImagePickerAsset, - launchImageLibraryAsync, - MediaTypeOptions, - UIImagePickerPreferredAssetRepresentationMode, -} from 'expo-image-picker' +import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import * as Prompt from '#/components/Prompt' +import {pickVideo} from './pickVideo' const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds @@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { Keyboard.dismiss() control.open() } else { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Videos, - quality: 1, - legacy: true, - preferredAssetRepresentationMode: - UIImagePickerPreferredAssetRepresentationMode.Current, - }) + const response = await pickVideo() if (response.assets && response.assets.length > 0) { const asset = response.assets[0] try { if (isWeb) { + // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) + if (asset.duration && asset.duration > VIDEO_MAX_DURATION) { + throw Error(_(msg`Videos must be less than 60 seconds long`)) + } // compression step on native converts to mp4, so no need to check there - const mimeType = getMimeType(asset) if ( - !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) + !SUPPORTED_MIME_TYPES.includes( + asset.mimeType as SupportedMimeTypes, + ) ) { - throw Error(_(msg`Unsupported video type: ${mimeType}`)) + throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) } } else { if (typeof asset.duration !== 'number') { @@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { ) } - -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType - } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') - } - return asset.mimeType -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index fff7545a522..255174beabc 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -20,7 +20,6 @@ export function VideoPreview({ asset: ImagePickerAsset video: CompressedVideo isActivePost: boolean - setDimensions: (width: number, height: number) => void clear: () => void }) { const t = useTheme() diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx index 5b3f727a9b5..f20f8b383c8 100644 --- a/src/view/com/composer/videos/VideoPreview.web.tsx +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -1,4 +1,3 @@ -import {useEffect, useRef} from 'react' import {View} from 'react-native' import {ImagePickerAsset} from 'expo-image-picker' import {msg} from '@lingui/macro' @@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -const MAX_DURATION = 60 - export function VideoPreview({ asset, video, - setDimensions, + clear, }: { asset: ImagePickerAsset video: CompressedVideo - setDimensions: (width: number, height: number) => void + clear: () => void }) { - const ref = useRef(null) const {_} = useLingui() + // TODO: figure out how to pause a GIF for reduced motion + // it's not possible using an img tag -sfn const autoplayDisabled = useAutoplayDisabled() - useEffect(() => { - if (!ref.current) return - - const abortController = new AbortController() - const {signal} = abortController - ref.current.addEventListener( - 'loadedmetadata', - function () { - setDimensions(this.videoWidth, this.videoHeight) - if (!isNaN(this.duration)) { - if (this.duration > MAX_DURATION) { - Toast.show( - _(msg`Videos must be less than 60 seconds long`), - 'xmark', - ) - clear() - } - } - }, - {signal}, - ) - ref.current.addEventListener( - 'error', - () => { - Toast.show(_(msg`Could not process your video`), 'xmark') - clear() - }, - {signal}, - ) - - return () => { - abortController.abort() - } - }, [setDimensions, _, clear]) - let aspectRatio = asset.width / asset.height if (isNaN(aspectRatio)) { @@ -83,19 +46,34 @@ export function VideoPreview({ a.relative, ]}> -