From 47d2f0dfa7e3f387f2ad7c5f5fac28cf248d7be5 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 7 Jan 2025 22:35:27 +0800 Subject: [PATCH 01/11] feat: try shared element animation --- apps/mobile/app.config.ts | 2 +- .../components/common/AnimatedComponents.tsx | 3 + apps/mobile/src/modules/entry/ctx.ts | 5 + apps/mobile/src/modules/entry/data.ts | 1249 +++++++++++++++++ apps/mobile/src/modules/entry/gird.tsx | 48 + .../mobile/src/screens/(headless)/_layout.tsx | 27 +- .../(headless)/entries/[entryId]/index.tsx | 86 ++ 7 files changed, 1413 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/modules/entry/ctx.ts create mode 100644 apps/mobile/src/modules/entry/data.ts create mode 100644 apps/mobile/src/modules/entry/gird.tsx create mode 100644 apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 95cfea0af5..141b9995d7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -28,7 +28,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ icon: iconPath, scheme: "follow", userInterfaceStyle: "automatic", - newArchEnabled: true, + // newArchEnabled: true, ios: { supportsTablet: true, bundleIdentifier: "is.follow", diff --git a/apps/mobile/src/components/common/AnimatedComponents.tsx b/apps/mobile/src/components/common/AnimatedComponents.tsx index b2f444054b..97bd5f87ab 100644 --- a/apps/mobile/src/components/common/AnimatedComponents.tsx +++ b/apps/mobile/src/components/common/AnimatedComponents.tsx @@ -1,5 +1,8 @@ +import { Image as ExpoImage } from "expo-image" import { Animated, FlatList, ScrollView, TouchableOpacity } from "react-native" +import ReAnimated from "react-native-reanimated" +export const ReAnimatedExpoImage = ReAnimated.createAnimatedComponent(ExpoImage) export const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView) export const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) diff --git a/apps/mobile/src/modules/entry/ctx.ts b/apps/mobile/src/modules/entry/ctx.ts new file mode 100644 index 0000000000..492fb0fceb --- /dev/null +++ b/apps/mobile/src/modules/entry/ctx.ts @@ -0,0 +1,5 @@ +import { createContext, useContext } from "react" + +const SharedElementAnimationContext = createContext<boolean>(true) +export const SharedElementAnimationContextProvider = SharedElementAnimationContext.Provider +export const useShouldAnimate = () => useContext(SharedElementAnimationContext) diff --git a/apps/mobile/src/modules/entry/data.ts b/apps/mobile/src/modules/entry/data.ts new file mode 100644 index 0000000000..7a6953fef9 --- /dev/null +++ b/apps/mobile/src/modules/entry/data.ts @@ -0,0 +1,1249 @@ +export const DATA = [ + { + read: true, + view: 2, + entries: { + id: "99090899709506560", + title: "Color of Emotions", + url: "https://1x.com/photo/3031649", + description: "Color of Emotions by Seray AK", + guid: "1x-3031649", + author: "Seray AK", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T13:55:22.089Z", + publishedAt: "2025-01-06T13:55:22.089Z", + media: [ + { + url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LZL3*-IA?ZxZ~VV@tRxsIVt7j[WV", + }, + // { + // url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", + // type: "photo", + // width: 2500, + // height: 1667, + // blurhash: "LZL3*-IA?ZxZ~VV@tRxsIVt7j[WV", + // }, + { + url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", + type: "photo", + width: 2500, + height: 1875, + blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", + title: "Color of Emotions", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99090899709506561", + title: "The Alps", + url: "https://1x.com/photo/3031271", + description: "The Alps by Ricarda V", + guid: "1x-3031271", + author: "Ricarda V", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T13:55:22.088Z", + publishedAt: "2025-01-06T13:55:22.088Z", + media: [ + { + url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", + type: "photo", + width: 1333, + height: 2000, + blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", + }, + { + url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", + type: "photo", + width: 1333, + height: 2000, + blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", + title: "The Alps", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + // { + // read: true, + // view: 2, + // entries: { + // id: "99090899709506562", + // title: "big face", + // url: "https://1x.com/photo/3031638", + // description: "big face by miyamoto", + // guid: "1x-3031638", + // author: "miyamoto", + // authorUrl: null, + // authorAvatar: null, + // insertedAt: "2025-01-06T13:55:22.087Z", + // publishedAt: "2025-01-06T13:55:22.087Z", + // media: [ + // { + // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", + // type: "photo", + // width: 2500, + // height: 1875, + // blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", + // }, + // { + // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", + // type: "photo", + // width: 2500, + // height: 1875, + // blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", + // }, + // ], + // categories: null, + // attachments: [ + // { + // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", + // title: "big face", + // mime_type: "image/jpg", + // }, + // ], + // extra: null, + // language: null, + // }, + // feeds: { + // type: "feed", + // id: "41375451836487680", + // url: "rsshub://1x/latest/awarded", + // title: "1x.com • In Pursuit of the Sublime", + // description: + // "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + // siteUrl: "https://1x.com/gallery/latest/awarded", + // image: "https://1x.com/assets/img/1x-logo-1.png", + // errorMessage: null, + // errorAt: null, + // ownerUserId: null, + // }, + // collections: null, + // subscriptions: { + // category: null, + // }, + // settings: { + // silence: true, + // }, + // }, + { + read: true, + view: 2, + entries: { + id: "99090899709506563", + title: "Through the Shadow", + url: "https://1x.com/photo/3031146", + description: "Through the Shadow by MingLun Tsai", + guid: "1x-3031146", + author: "MingLun Tsai", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T13:55:22.086Z", + publishedAt: "2025-01-06T13:55:22.086Z", + media: [ + { + url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", + type: "photo", + width: 1332, + height: 2000, + blurhash: "LHC?r]_3-;%M~qxuj[WB9FM{M{WB", + }, + { + url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", + type: "photo", + width: 1332, + height: 2000, + blurhash: "LHC?r]_3-;%M~qxuj[WB9FM{M{WB", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", + title: "Through the Shadow", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99090899709506564", + title: "Fanal forest.", + url: "https://1x.com/photo/3028102", + description: "Fanal forest. by Milosz Wilczynski", + guid: "1x-3028102", + author: "Milosz Wilczynski", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T13:55:22.085Z", + publishedAt: "2025-01-06T13:55:22.085Z", + media: [ + { + url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", + type: "photo", + width: 2500, + height: 1578, + blurhash: "LlL;me~qM{t7?b%MWBofD%Rjt7t7", + }, + { + url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", + type: "photo", + width: 2500, + height: 1578, + blurhash: "LlL;me~qM{t7?b%MWBofD%Rjt7t7", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", + title: "Fanal forest.", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99090899709506565", + title: "White Stage", + url: "https://1x.com/photo/3031020", + description: "White Stage by Michiko Ôtomo", + guid: "1x-3031020", + author: "Michiko Ôtomo", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T13:55:22.084Z", + publishedAt: "2025-01-06T13:55:22.084Z", + media: [ + { + url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LTECwd%MD%Rj00WBt7WBofRjofof", + }, + { + url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LTECwd%MD%Rj00WBt7WBofRjofof", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", + title: "White Stage", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99073011860296704", + title: "Echoes of Stillness", + url: "https://1x.com/photo/3031225", + description: "Echoes of Stillness by Mary Cheng", + guid: "1x-3031225", + author: "Mary Cheng", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T12:44:21.589Z", + publishedAt: "2025-01-06T12:44:21.589Z", + media: [ + { + url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", + type: "photo", + width: 2500, + height: 1629, + blurhash: "LHHLl1IU-;j[D%xuj[fQ~qxuj[Rj", + }, + { + url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", + type: "photo", + width: 2500, + height: 1629, + blurhash: "LHHLl1IU-;j[D%xuj[fQ~qxuj[Rj", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", + title: "Echoes of Stillness", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99073011860296705", + title: "Praise of symmetry", + url: "https://1x.com/photo/3031206", + description: "Praise of symmetry by Martin Kucera AFIAP AZSF", + guid: "1x-3031206", + author: "Martin Kucera AFIAP AZSF", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T12:44:21.588Z", + publishedAt: "2025-01-06T12:44:21.588Z", + media: [ + { + url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "L6AA{.tR?w%gtRjbj]jb?wkBoMof", + }, + { + url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "L6AA{.tR?w%gtRjbj]jb?wkBoMof", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", + title: "Praise of symmetry", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99073011860296706", + title: "Pas de visage", + url: "https://1x.com/photo/3026370", + description: "Pas de visage by Kurosaki Sangan", + guid: "1x-3026370", + author: "Kurosaki Sangan", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T12:44:21.587Z", + publishedAt: "2025-01-06T12:44:21.587Z", + media: [ + { + url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", + type: "photo", + width: 1334, + height: 2000, + blurhash: "LAAc_C~qXTxaaKRjRjf6S$ofM{IU", + }, + { + url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", + type: "photo", + width: 1334, + height: 2000, + blurhash: "LAAc_C~qXTxaaKRjRjf6S$ofM{IU", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", + title: "Pas de visage", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99073011860296707", + title: "Double-helix staircase", + url: "https://1x.com/photo/3031616", + description: "Double-helix staircase by konglingming", + guid: "1x-3031616", + author: "konglingming", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T12:44:21.586Z", + publishedAt: "2025-01-06T12:44:21.586Z", + media: [ + { + url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LEETVs9bOR$MNNt8e,wb1Ixt;NxD", + }, + { + url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LEETVs9bOR$MNNt8e,wb1Ixt;NxD", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", + title: "Double-helix staircase", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99073011860296708", + title: "Old / New", + url: "https://1x.com/photo/3031195", + description: "Old / New by Jürgen Muß", + guid: "1x-3031195", + author: "Jürgen Muß", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T12:44:21.585Z", + publishedAt: "2025-01-06T12:44:21.585Z", + media: [ + { + url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LCAAgu8^IUV@M|kCt7of4TtR%Na}", + }, + { + url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", + type: "photo", + width: 2500, + height: 1667, + blurhash: "LCAAgu8^IUV@M|kCt7of4TtR%Na}", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", + title: "Old / New", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752896", + title: "Hong Kong Cityscape", + url: "https://1x.com/photo/3026523", + description: "Hong Kong Cityscape by JUNGJAEYONG", + guid: "1x-3026523", + author: "JUNGJAEYONG", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.398Z", + publishedAt: "2025-01-06T11:33:18.398Z", + media: [ + { + url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", + type: "photo", + width: 1331, + height: 2000, + blurhash: "LA9Qmq%M00-;of-;%MRj4nt7~q%M", + }, + { + url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", + type: "photo", + width: 1331, + height: 2000, + blurhash: "LA9Qmq%M00-;of-;%MRj4nt7~q%M", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", + title: "Hong Kong Cityscape", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752897", + title: "Salt Lives #93", + url: "https://1x.com/photo/3009775", + description: "Salt Lives #93 by Josefina Melo", + guid: "1x-3009775", + author: "Josefina Melo", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.397Z", + publishedAt: "2025-01-06T11:33:18.397Z", + media: [ + { + url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "LUC%8J~q-;xu-;-;xuofD%M{RjWB", + }, + { + url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "LUC%8J~q-;xu-;-;xuofD%M{RjWB", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", + title: "Salt Lives #93", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752898", + title: "Dockland Office Building", + url: "https://1x.com/photo/3031261", + description: "Dockland Office Building by jordiegeatorrent", + guid: "1x-3031261", + author: "jordiegeatorrent", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.396Z", + publishedAt: "2025-01-06T11:33:18.396Z", + media: [ + { + url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", + type: "photo", + width: 2500, + height: 1872, + blurhash: "LE8;V?IUM{j[M{t7M{fQ00t7xuay", + }, + { + url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", + type: "photo", + width: 2500, + height: 1872, + blurhash: "LE8;V?IUM{j[M{t7M{fQ00t7xuay", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", + title: "Dockland Office Building", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752899", + title: "WAY OF LIGHT", + url: "https://1x.com/photo/3031265", + description: "WAY OF LIGHT by Jesus Concepcion Alvarado", + guid: "1x-3031265", + author: "Jesus Concepcion Alvarado", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.395Z", + publishedAt: "2025-01-06T11:33:18.395Z", + media: [ + { + url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", + type: "photo", + width: 2500, + height: 1668, + blurhash: "LI9Qgg?b%Lbb_4%Mt6WqNhNKR-fS", + }, + { + url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", + type: "photo", + width: 2500, + height: 1668, + blurhash: "LI9Qgg?b%Lbb_4%Mt6WqNhNKR-fS", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", + title: "WAY OF LIGHT", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752900", + title: "Christmas market, Bocholt Germany", + url: "https://1x.com/photo/3030503", + description: "Christmas market, Bocholt Germany by Jan van der Linden", + guid: "1x-3030503", + author: "Jan van der Linden", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.394Z", + publishedAt: "2025-01-06T11:33:18.394Z", + media: [ + { + url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", + type: "photo", + width: 2500, + height: 1669, + blurhash: "LZFhIqbuI:$%}@X7I;xF$jjFNHWB", + }, + { + url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", + type: "photo", + width: 2500, + height: 1669, + blurhash: "LZFhIqbuI:$%}@X7I;xF$jjFNHWB", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", + title: "Christmas market, Bocholt Germany", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99055140412752901", + title: "New World Center Entrance", + url: "https://1x.com/photo/3031617", + description: "New World Center Entrance by Ivan Huang", + guid: "1x-3031617", + author: "Ivan Huang", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T11:33:18.393Z", + publishedAt: "2025-01-06T11:33:18.393Z", + media: [ + { + url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", + type: "photo", + width: 2500, + height: 1063, + blurhash: "LMAAaaIUofIUt7M{%MRj00t7xuxu", + }, + { + url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", + type: "photo", + width: 2500, + height: 1063, + blurhash: "LMAAaaIUofIUt7M{%MRj00t7xuxu", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", + title: "New World Center Entrance", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99037256169255936", + title: "Evolution", + url: "https://1x.com/photo/3031278", + description: "Evolution by Giorgio Toniolo", + guid: "1x-3031278", + author: "Giorgio Toniolo", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T10:22:16.859Z", + publishedAt: "2025-01-06T10:22:16.859Z", + media: [ + { + url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", + type: "photo", + width: 1845, + height: 2500, + blurhash: "L38qNg0000%MD%ay-;D%xuxuIUIU", + }, + { + url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", + type: "photo", + width: 1845, + height: 2500, + blurhash: "L38qNg0000%MD%ay-;D%xuxuIUIU", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", + title: "Evolution", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99037256169255937", + title: "Abounded scooter", + url: "https://1x.com/photo/3030870", + description: "Abounded scooter by Gilad Topaz", + guid: "1x-3030870", + author: "Gilad Topaz", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T10:22:16.858Z", + publishedAt: "2025-01-06T10:22:16.858Z", + media: [ + { + url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", + type: "photo", + width: 1600, + height: 2000, + blurhash: "LDB.}|X#0_m.RhbZI.aPx[wgogOS", + }, + { + url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", + type: "photo", + width: 1600, + height: 2000, + blurhash: "LDB.}|X#0_m.RhbZI.aPx[wgogOS", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", + title: "Abounded scooter", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, + { + read: true, + view: 2, + entries: { + id: "99037256169255938", + title: "***", + url: "https://1x.com/photo/3029819", + description: "*** by Eleonora Fridman", + guid: "1x-3029819", + author: "Eleonora Fridman", + authorUrl: null, + authorAvatar: null, + insertedAt: "2025-01-06T10:22:16.857Z", + publishedAt: "2025-01-06T10:22:16.857Z", + media: [ + { + url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "LBIN,M~p5Qw]-B?GI.I:I:oNn+NH", + }, + { + url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", + type: "photo", + width: 2000, + height: 2000, + blurhash: "LBIN,M~p5Qw]-B?GI.I:I:oNn+NH", + }, + ], + categories: null, + attachments: [ + { + url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", + title: "***", + mime_type: "image/jpg", + }, + ], + extra: null, + language: null, + }, + feeds: { + type: "feed", + id: "41375451836487680", + url: "rsshub://1x/latest/awarded", + title: "1x.com • In Pursuit of the Sublime", + description: + "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", + siteUrl: "https://1x.com/gallery/latest/awarded", + image: "https://1x.com/assets/img/1x-logo-1.png", + errorMessage: null, + errorAt: null, + ownerUserId: null, + }, + collections: null, + subscriptions: { + category: null, + }, + settings: { + silence: true, + }, + }, +] diff --git a/apps/mobile/src/modules/entry/gird.tsx b/apps/mobile/src/modules/entry/gird.tsx new file mode 100644 index 0000000000..d0edc8e21d --- /dev/null +++ b/apps/mobile/src/modules/entry/gird.tsx @@ -0,0 +1,48 @@ +import { MasonryFlashList } from "@shopify/flash-list" +import { Link } from "expo-router" +import { Pressable, View } from "react-native" +import { SharedTransition } from "react-native-reanimated" + +import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import { ThemedText } from "@/src/components/common/ThemedText" + +import { DATA } from "./data" + +const transition = SharedTransition.duration(300) + +export function EntryColumnGrid() { + return ( + <View className="flex-1 flex-row bg-gray-50"> + <MasonryFlashList + data={DATA} + numColumns={2} + keyExtractor={(item) => item.entries.id} + contentContainerClassName="p-1" + renderItem={({ item }) => { + const media = item.entries.media.find((media) => media.type === "photo") + return ( + <View className="m-1 overflow-hidden rounded-md bg-white"> + <Link href={`/entries/${item.entries.id}`} asChild> + <Pressable> + <ReAnimatedExpoImage + source={{ uri: media?.url }} + style={{ + width: "100%", + aspectRatio: + media?.height && media.width ? media.width / media.height : 9 / 16, + }} + sharedTransitionTag={`entry-image-${media?.url}`} + sharedTransitionStyle={transition} + allowDownscaling={false} + /> + </Pressable> + </Link> + + <ThemedText className="p-2">{item.entries.title}</ThemedText> + </View> + ) + }} + /> + </View> + ) +} diff --git a/apps/mobile/src/screens/(headless)/_layout.tsx b/apps/mobile/src/screens/(headless)/_layout.tsx index 66955b9add..79d59a02dd 100644 --- a/apps/mobile/src/screens/(headless)/_layout.tsx +++ b/apps/mobile/src/screens/(headless)/_layout.tsx @@ -1,17 +1,32 @@ import { Stack } from "expo-router" +import { useState } from "react" import { useColorScheme } from "react-native" +import { SharedElementAnimationContextProvider } from "@/src/modules/entry/ctx" import { getSystemBackgroundColor } from "@/src/theme/utils" export default function HeadlessLayout() { useColorScheme() const systemBackgroundColor = getSystemBackgroundColor() + const [shouldAnimate, setShouldAnimate] = useState(true) return ( - <Stack - screenOptions={{ - contentStyle: { backgroundColor: systemBackgroundColor }, - headerShown: false, - }} - /> + <SharedElementAnimationContextProvider value={shouldAnimate}> + <Stack + screenOptions={{ + contentStyle: { backgroundColor: systemBackgroundColor }, + headerShown: false, + }} + screenListeners={{ + transitionEnd: (e) => { + // disable shared element animation when navigating back to the start screen + const screenToStart = "index" + + if (e.target?.startsWith(screenToStart)) { + setShouldAnimate(!e.data.closing) + } + }, + }} + /> + </SharedElementAnimationContextProvider> ) } diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx new file mode 100644 index 0000000000..99130e1ae3 --- /dev/null +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -0,0 +1,86 @@ +import { Stack, useLocalSearchParams } from "expo-router" +import { useState } from "react" +import { Dimensions, View } from "react-native" +import PagerView from "react-native-pager-view" + +import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import { useShouldAnimate } from "@/src/modules/entry/ctx" +import { DATA } from "@/src/modules/entry/data" + +export default function EntryDetailPage() { + const { entryId } = useLocalSearchParams() + const initialIndex = DATA.findIndex((item) => item.entries.id === entryId) + const item = DATA[initialIndex] + const mediaList = item?.entries.media + .filter((media) => media.type === "photo") + .filter((media, index) => { + return item.entries.media.findIndex((m) => m.url === media.url) === index + }) + const windowWidth = Dimensions.get("window").width + const maxPhotoHeight = Math.max( + ...mediaList + .filter((media) => media.height && media.width) + .map((media) => { + return windowWidth * (media.height / media.width) + }), + ) + + const shouldAnimate = useShouldAnimate() + + const [currentPageIndex, setCurrentPageIndex] = useState(0) + + return ( + <> + <Stack.Screen options={{ animation: "fade" }} /> + <View className="flex-1 p-safe"> + <View + style={{ + height: maxPhotoHeight > 0 ? maxPhotoHeight : "80%", + maxHeight: "80%", + }} + > + {mediaList.length > 0 && ( + <PagerView + key={item.entries.id} + style={{ flex: 1 }} + initialPage={0} + orientation="horizontal" + onPageSelected={(event) => { + setCurrentPageIndex(event.nativeEvent.position) + }} + > + {mediaList.map((media) => { + return ( + <View key={media.url} className="bg-gray-6 flex-1 justify-center"> + <ReAnimatedExpoImage + source={{ uri: media?.url }} + style={{ + width: "100%", + aspectRatio: + media?.height && media.width ? media.width / media.height : 9 / 16, + }} + sharedTransitionTag={shouldAnimate ? `entry-image-${media?.url}` : undefined} + allowDownscaling={false} + /> + </View> + ) + })} + </PagerView> + )} + <View className="absolute inset-x-0 bottom-1 w-full flex-row items-center justify-center gap-2"> + {Array.from({ length: mediaList.length }).map((_, index) => { + return ( + <View + key={index} + className={`size-2 rounded-full ${ + index === currentPageIndex ? "bg-red" : "bg-gray-2" + }`} + /> + ) + })} + </View> + </View> + </View> + </> + ) +} From 603069425a985859c64eb091d4f30022ac5a69a8 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 7 Jan 2025 22:44:31 +0800 Subject: [PATCH 02/11] chore: update --- .../(headless)/entries/[entryId]/index.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index 99130e1ae3..cba1c648ba 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -31,7 +31,7 @@ export default function EntryDetailPage() { return ( <> - <Stack.Screen options={{ animation: "fade" }} /> + <Stack.Screen options={{ animation: "fade", animationDuration: 300 }} /> <View className="flex-1 p-safe"> <View style={{ @@ -67,18 +67,20 @@ export default function EntryDetailPage() { })} </PagerView> )} - <View className="absolute inset-x-0 bottom-1 w-full flex-row items-center justify-center gap-2"> - {Array.from({ length: mediaList.length }).map((_, index) => { - return ( - <View - key={index} - className={`size-2 rounded-full ${ - index === currentPageIndex ? "bg-red" : "bg-gray-2" - }`} - /> - ) - })} - </View> + {mediaList.length > 1 && ( + <View className="absolute inset-x-0 bottom-1 w-full flex-row items-center justify-center gap-2"> + {Array.from({ length: mediaList.length }).map((_, index) => { + return ( + <View + key={index} + className={`size-2 rounded-full ${ + index === currentPageIndex ? "bg-red" : "bg-gray-2" + }`} + /> + ) + })} + </View> + )} </View> </View> </> From ed844391ccb685961e8f9c5d3225c09c71bf41e3 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 9 Jan 2025 11:08:15 +0800 Subject: [PATCH 03/11] feat: handle video --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 1 + apps/mobile/src/modules/entry/data.ts | 39 +++++++--- apps/mobile/src/modules/entry/gird.tsx | 54 ++++++++++---- .../(headless)/entries/[entryId]/index.tsx | 74 ++++++++++++++----- pnpm-lock.yaml | 22 ++++++ 6 files changed, 147 insertions(+), 44 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 141b9995d7..05aa8e5332 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -89,6 +89,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ], "expo-apple-authentication", + "expo-av", [require("./scripts/with-follow-assets.js")], ], experiments: { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 790d8dfa54..4c02120cfd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -36,6 +36,7 @@ "es-toolkit": "1.29.0", "expo": "52.0.18", "expo-apple-authentication": "~7.1.2", + "expo-av": "~15.0.1", "expo-blur": "~14.0.1", "expo-build-properties": "^0.13.1", "expo-clipboard": "~7.0.0", diff --git a/apps/mobile/src/modules/entry/data.ts b/apps/mobile/src/modules/entry/data.ts index 7a6953fef9..ff2d6331b7 100644 --- a/apps/mobile/src/modules/entry/data.ts +++ b/apps/mobile/src/modules/entry/data.ts @@ -83,20 +83,35 @@ export const DATA = [ insertedAt: "2025-01-06T13:55:22.088Z", publishedAt: "2025-01-06T13:55:22.088Z", media: [ + // { + // url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", + // type: "photo", + // width: 1333, + // height: 2000, + // blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", + // }, + // { + // url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", + // type: "photo", + // width: 1333, + // height: 2000, + // blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", + // }, { - url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", - type: "photo", - width: 1333, - height: 2000, - blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", - }, - { - url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", - type: "photo", - width: 1333, - height: 2000, - blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", + url: "https://video.twimg.com/amplify_video/1848739714049908736/vid/avc1/1280x720/jzWeEF8Xd3WmYp5s.mp4?tag=16", + type: "video", + width: 1280, + height: 720, + preview_image_url: + "https://pbs.twimg.com/amplify_video_thumb/1848739714049908736/img/OJbHuDJHbYcA6zzW.jpg", }, + // { + // url: "https://media.st.dl.eccdnx.com/steam/apps/2074800/extras/pic1.png?t=1673603154", + // type: "photo", + // width: 616, + // height: 350, + // blurhash: "LLLW6Yg7xzM.?aj2+c$,t*R*tPxV", + // }, ], categories: null, attachments: [ diff --git a/apps/mobile/src/modules/entry/gird.tsx b/apps/mobile/src/modules/entry/gird.tsx index d0edc8e21d..b56764038a 100644 --- a/apps/mobile/src/modules/entry/gird.tsx +++ b/apps/mobile/src/modules/entry/gird.tsx @@ -10,31 +10,59 @@ import { DATA } from "./data" const transition = SharedTransition.duration(300) +type EntryList = Array<{ + entries: { + id: string + title: string + media: Array<{ + type: string + url: string + width?: number + height?: number + preview_image_url?: string + }> + } +}> + export function EntryColumnGrid() { return ( <View className="flex-1 flex-row bg-gray-50"> <MasonryFlashList - data={DATA} + data={DATA as EntryList} numColumns={2} keyExtractor={(item) => item.entries.id} contentContainerClassName="p-1" renderItem={({ item }) => { - const media = item.entries.media.find((media) => media.type === "photo") + const photo = item.entries.media.find((media) => media.type === "photo") + const video = item.entries.media.find((media) => media.type === "video") + const imageUrl = photo?.url || video?.preview_image_url + const aspectRatio = + photo?.height && photo.width + ? photo.width / photo.height + : video?.height && video.width + ? video.width / video.height + : 16 / 9 + return ( <View className="m-1 overflow-hidden rounded-md bg-white"> <Link href={`/entries/${item.entries.id}`} asChild> <Pressable> - <ReAnimatedExpoImage - source={{ uri: media?.url }} - style={{ - width: "100%", - aspectRatio: - media?.height && media.width ? media.width / media.height : 9 / 16, - }} - sharedTransitionTag={`entry-image-${media?.url}`} - sharedTransitionStyle={transition} - allowDownscaling={false} - /> + {imageUrl ? ( + <ReAnimatedExpoImage + source={{ uri: imageUrl }} + style={{ + width: "100%", + aspectRatio, + }} + sharedTransitionTag={`entry-image-${imageUrl}`} + sharedTransitionStyle={transition} + allowDownscaling={false} + /> + ) : ( + <View className="aspect-video w-full items-center justify-center"> + <ThemedText className="text-center">No media available</ThemedText> + </View> + )} </Pressable> </Link> diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index cba1c648ba..443800d94f 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -1,23 +1,72 @@ +import type { AVPlaybackStatus } from "expo-av" +import { Video } from "expo-av" import { Stack, useLocalSearchParams } from "expo-router" import { useState } from "react" -import { Dimensions, View } from "react-native" +import { Dimensions, Text, View } from "react-native" import PagerView from "react-native-pager-view" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" import { useShouldAnimate } from "@/src/modules/entry/ctx" import { DATA } from "@/src/modules/entry/data" +function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) { + const isVideo = media.type === "video" + const imageUrl = isVideo ? media.preview_image_url : media.url + const videoUrl = media.url + + const [status, setStatus] = useState<AVPlaybackStatus | null>(null) + if (!imageUrl && !videoUrl) { + return null + } + + return ( + <> + {isVideo && ( + <Video + source={{ uri: media.url }} + style={{ + width: "100%", + aspectRatio: media.width && media.height ? media.width / media.height : 1, + display: status?.isLoaded ? "flex" : "none", + }} + useNativeControls + shouldPlay + onPlaybackStatusUpdate={(status) => setStatus(() => status)} + /> + )} + <View className="bg-gray-6 flex-1 justify-center"> + {imageUrl ? ( + <ReAnimatedExpoImage + source={{ uri: imageUrl }} + style={{ + width: "100%", + aspectRatio: media?.height && media.width ? media.width / media.height : 9 / 16, + display: isVideo ? (status?.isLoaded ? "none" : "flex") : "flex", + }} + sharedTransitionTag={shouldAnimate && imageUrl ? `entry-image-${imageUrl}` : undefined} + allowDownscaling={false} + /> + ) : ( + <Text className="text-gray-4 text-center">No media</Text> + )} + </View> + </> + ) +} + export default function EntryDetailPage() { const { entryId } = useLocalSearchParams() const initialIndex = DATA.findIndex((item) => item.entries.id === entryId) const item = DATA[initialIndex] + const mediaList = item?.entries.media - .filter((media) => media.type === "photo") + .filter((media) => media.url) .filter((media, index) => { return item.entries.media.findIndex((m) => m.url === media.url) === index }) + const windowWidth = Dimensions.get("window").width - const maxPhotoHeight = Math.max( + const maxMediaHeight = Math.max( ...mediaList .filter((media) => media.height && media.width) .map((media) => { @@ -35,7 +84,7 @@ export default function EntryDetailPage() { <View className="flex-1 p-safe"> <View style={{ - height: maxPhotoHeight > 0 ? maxPhotoHeight : "80%", + height: maxMediaHeight > 0 ? maxMediaHeight : "80%", maxHeight: "80%", }} > @@ -50,25 +99,12 @@ export default function EntryDetailPage() { }} > {mediaList.map((media) => { - return ( - <View key={media.url} className="bg-gray-6 flex-1 justify-center"> - <ReAnimatedExpoImage - source={{ uri: media?.url }} - style={{ - width: "100%", - aspectRatio: - media?.height && media.width ? media.width / media.height : 9 / 16, - }} - sharedTransitionTag={shouldAnimate ? `entry-image-${media?.url}` : undefined} - allowDownscaling={false} - /> - </View> - ) + return <Media key={media.url} media={media} shouldAnimate={shouldAnimate} /> })} </PagerView> )} {mediaList.length > 1 && ( - <View className="absolute inset-x-0 bottom-1 w-full flex-row items-center justify-center gap-2"> + <View className="mt-2 w-full flex-row items-center justify-center gap-2"> {Array.from({ length: mediaList.length }).map((_, index) => { return ( <View diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1500a748ed..64ea29c7bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,6 +463,9 @@ importers: expo-apple-authentication: specifier: ~7.1.2 version: 7.1.2(yviy5suycsk3aacmyts4vro3la) + expo-av: + specifier: ~15.0.1 + version: 15.0.1(apfxbceiepjzb6wdecgahzu7ta) expo-blur: specifier: ~14.0.1 version: 14.0.1(vc5zx7mqwgzirpvpjamp5nboge) @@ -8453,6 +8456,17 @@ packages: react: '*' react-native: '*' + expo-av@15.0.1: + resolution: {integrity: sha512-2d4j7RTH9Y5nI1FoC0woRjqnj5G8sF0al2iW6kG21PPs/CgS4QARbHxMrPxKpUXrC6iu+6EMj2h5H6T0nqteFg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-blur@14.0.1: resolution: {integrity: sha512-3Q6jFBLbY8n2vwk28ycUC+eIlVhnlqwkXUKk/Lfaj+SGV3AZMQyrixe7OYwJdUfwqETBrnYYMB6uNrJzOSbG+g==} peerDependencies: @@ -23807,6 +23821,14 @@ snapshots: transitivePeerDependencies: - supports-color + expo-av@15.0.1(apfxbceiepjzb6wdecgahzu7ta): + dependencies: + expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1) + optionalDependencies: + react-native-web: 0.19.13(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + expo-blur@14.0.1(vc5zx7mqwgzirpvpjamp5nboge): dependencies: expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@expo/metro-runtime@4.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)))(bufferutil@4.0.8)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) From ee59d1a17bfa82c962ef0b60943d11850cb34f78 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:13:21 +0800 Subject: [PATCH 04/11] feat: html content --- .../src/components/ui/typography/HtmlWeb.tsx | 38 +++++++++++++- apps/mobile/src/modules/entry/data.ts | 52 +++++++++++++++++++ .../(headless)/entries/[entryId]/index.tsx | 24 +++++++-- packages/components/src/ui/markdown/html.tsx | 2 +- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx index d152110cb1..4fd5ee8262 100644 --- a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -4,11 +4,47 @@ import "@follow/components/assets/tailwind.css" import type { HtmlProps } from "@follow/components" import { Html } from "@follow/components" +import { useEffect } from "react" + +function useSize(callback: (size: [number, number]) => void) { + useEffect(() => { + const lastSize = [document.body.clientWidth, document.body.clientHeight] + + // Observe window size changes + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect + + if ( + width.toFixed(0) !== lastSize[0].toFixed(0) || + height.toFixed(0) !== lastSize[1].toFixed(0) + ) { + lastSize[0] = width + lastSize[1] = height + callback([width, height]) + } + } + }) + + observer.observe(document.body) + + callback([document.body.clientWidth, document.body.clientHeight]) + + return () => { + observer.disconnect() + } + }, [callback]) +} export default function HtmlWeb({ content, dom, + onLayout, ...options -}: { dom?: import("expo/dom").DOMProps } & HtmlProps) { +}: { + dom?: import("expo/dom").DOMProps + onLayout: (size: [number, number]) => void +} & HtmlProps) { + useSize(onLayout) return <Html content={content} {...options} /> } diff --git a/apps/mobile/src/modules/entry/data.ts b/apps/mobile/src/modules/entry/data.ts index ff2d6331b7..7b541fa20d 100644 --- a/apps/mobile/src/modules/entry/data.ts +++ b/apps/mobile/src/modules/entry/data.ts @@ -7,6 +7,58 @@ export const DATA = [ title: "Color of Emotions", url: "https://1x.com/photo/3031649", description: "Color of Emotions by Seray AK", + content: `<p>If you've been following my work in open source, you might have noticed that I have a tendency to stick with zero-major versioning, like <code>v0.x.x</code>. For instance, as of writing this post, the latest version of UnoCSS is <a href="https://github.com/unocss/unocss/releases/tag/v0.65.3" target="_blank"><code>v0.65.3</code></a>, Slidev is <a href="https://github.com/slidevjs/slidev/releases/tag/v0.50.0" target="_blank"><code>v0.50.0</code></a>, and <code>unplugin-vue-components</code> is <a href="https://github.com/unplugin/unplugin-vue-components/releases/tag/v0.28.0" target="_blank"><code>v0.28.0</code></a>. Other projects, such as React Native is on <a href="https://github.com/facebook/react-native/releases/tag/v0.76.5" target="_blank"><code>v0.76.5</code></a>, and sharp is on <a href="https://github.com/lovell/sharp/releases/tag/v0.33.5" target="_blank"><code>v0.33.5</code></a>, also follow this pattern.</p> +<p>People often assume that a zero-major version indicates that the software is not ready for production. However, all of the projects mentioned here are quite stable and production-ready, used by millions of projects.</p> +<p><strong>Why?</strong> - I bet that's your question reading this.</p> +<h2>Versioning</h2> +<p>Version numbers act as snapshots of our codebase, helping us communicate changes effectively. For instance, we can say "it works in v1.3.2, but not in v1.3.3, there might be a regression." This makes it easier for maintainers to locate bugs by comparing the differences between these versions. A version is essentially a marker, a seal of the codebase at a specific point in time.</p> +<p>However, code is complex, and every change involves trade-offs. Describing how a change affects the code can be tricky, even with natural language. A version number alone can't capture all the nuances of a release. That's why we have changelogs, release notes, and commit messages to provide more context.</p> +<p>I see versioning as a way to communicate changes to users — a <strong>contract</strong> between the library and its users to ensure compatibility and stability during upgrades. As a user, you can't always tell what's changed between <code>v2.3.4</code> and <code>v2.3.5</code> without checking the changelog. But by looking at the numbers, you can infer that it's a patch release meant to fix bugs, which should be safe to upgrade. This ability to understand changes just by looking at the version number is possible because both the library maintainer and the users agree on the versioning scheme.</p> +<p>Since versioning is only a contract, you shouldn't blindly trust it. It serves as an indication to help you decide when to take a closer look at the changelog and be cautious about upgrading. But it's not a guarantee that everything will work as expected, every change might introduce behavior changes whether it's intended or not.</p> +<h2>Semantic Versioning</h2> +<p>In the JavaScript ecosystem, especially for packages published on npm, we follow a convention known as <a href="https://semver.org/" target="_blank">Semantic Versioning</a>, or SemVer for short. A SemVer version number consists of three parts: <code>MAJOR.MINOR.PATCH</code>. The rules are straightforward:</p> +<ul> +<li><strong>MAJOR</strong>: Increment when you make incompatible API changes.</li> +<li><strong>MINOR</strong>: Increment when you add functionality in a backwards-compatible manner.</li> +<li><strong>PATCH</strong>: Increment when you make backwards-compatible bug fixes.</li> +</ul> +<p>Package managers we use, like <code>npm</code>, <code>pnpm</code>, and <code>yarn</code>, all operate under the assumption that every package on npm adheres to SemVer. When you or a package specifies a dependency with a version range, such as <code>^1.2.3</code>, it indicates that you are comfortable with upgrading to any version that shares the same major version (<code>1.x.x</code>). In these scenarios, package managers will automatically determine the best version to install based on what is most suitable for your specific project.</p> +<p>This convention works well technically. If a package releases a new major version <code>v2.0.0</code>, your package manager won't install it if your specified range is <code>^1.2.3</code>. This prevents unexpected breaking changes from affecting your project until you manually update the version range.</p> +<p>Humans perceive numbers on a logarithmic scale. We tend to see <code>v2.0</code> to <code>v3.0</code> as a huge, groundbreaking change, while <code>v125.0</code> to <code>v126.0</code> seems trivial, even though both indicate incompatible API changes in SemVer. This perception can make maintainers hesitant to bump the major version for minor breaking changes, leading to the accumulation of many breaking changes in a single major release, making upgrades harder for users. Conversely, with something like <code>v125.0</code>, it becomes difficult to convey the significance of a major change, as the jump to <code>v126.0</code> appears minor.</p> +<h2>Progressive</h2> +<p>I strongly believe in the principle of progressiveness. Rather than making a giant leap to a significantly higher stage all at once, progressiveness allows users to adopt changes gradually at their own pace. It provides opportunities to pause and assess, making it easier to understand the impact of each change.</p> +<figure> + <img src="https://antfu.me/images/epoch-semver-progressive-1.png" alt="Progressive as Stairs" border="~ base rounded-xl"> + Progressive as Stairs - a screenshot of my talk <a href="/talks#the-progressive-path" target="_blank">The Progressive Path</a> +</figure> +<p>I believe we should apply the same principle to versioning. Instead of treating a major version as a massive overhaul, we can break it down into smaller, more manageable updates. For example, rather than releasing <code>v2.0.0</code> with 10 breaking changes from <code>v1.x</code>, we could distribute these changes across several smaller major releases. This way, we might release <code>v2.0</code> with 2 breaking changes, followed by <code>v3.0</code> with 1 breaking change, and so on. This approach makes it easier for users to adopt changes gradually and reduces the risk of overwhelming them with too many changes at once.</p> +<figure> + <img src="https://antfu.me/images/epoch-semver-progressive-2.png" alt="Progressive on Breaking Changes" border="~ base rounded-xl"> + Progressive on Breaking Changes - a screenshot of my talk <a href="/talks#the-progressive-path" target="_blank">The Progressive Path</a> +</figure> +<h2>Leading Zero Major Versioning</h2> +<p>The reason I've stuck with <code>v0.x.x</code> is my own unconventional approach to versioning. I prefer to introduce necessary and minor breaking changes early on, making upgrades easier, without causing alarm that typically comes with major version jumps like <code>v2</code> to <code>v3</code>. Some changes might be "technically" breaking but don't impact 99.9% of users in practice. Breaking changes are relative; even a bug fix can be breaking for those relying on the previous behavior (but that's another topic for discussion :P). There's a special rule in SemVer that states <strong>when the leading major version is <code>0</code>, every minor version bump is considered breaking</strong>. I've been leveraging this rule to navigate the system more flexibly. I kinda abuse that rule to workaround the limitation of SemVer.</p> +<p>Of course, zero-major versioning is not the only solution to be progressive. We can see that tools like <a href="https://nodejs.org/en" target="_blank">Node.js</a>, <a href="https://vite.dev/" target="_blank">Vite</a>, <a href="https://vitest.dev/" target="_blank">Vitest</a> are rolling out major versions in consistent intervals, with a minimal set of breaking changes in each release that are easy to adopt.</p> +<p>I have to admit that sticking to zero-major versioning isn't the best practice. While I aimed for more granular versioning to improve communication, using zero-major versioning has actually limited my ability to convey changes effectively. In reality, I've been wasting a valuable part of the versioning scheme due to my peculiar insistence.</p> +<p>Thus here, I am proposing to change.</p> +<h2>Epoch Semantic Versioning</h2> +<p><a href="https://x.com/antfu7/status/1679184417930059777" target="_blank">In an ideal world, I would wish SemVer to have four numbers: <code>EPOCH.MAJOR.MINOR.PATCH</code></a>. The <code>EPOCH</code> version is for those big announcements, while <code>MAJOR</code> is for technical incompatible API changes that might not be significant. This way, we can have a more granular way to communicate changes. Similar we also have <a href="https://github.com/romversioning/romver" target="_blank">Romantic Versioning that propose <code>HUMAN.MAJOR.MINOR</code></a>. But of course, it's too late for the entire ecosystem to adopt a new versioning scheme.</p> +<p>If we can't change SemVer, maybe we can at least extend it. I am proposing a new versioning scheme called <strong>Epoch Semantic Versioning</strong> (Epoch SemVer for short). Build on top of the structure of <code>MAJOR.MINOR.PATCH</code>, extend the first number to be the combination of <code>EPOCH</code> and <code>MAJOR</code>. To put a difference between them, we use a third digit to represent <code>EPOCH</code>, which gives <code>MAJOR</code> a range from 0 to 99. This way, it follows the exact same rules as SemVer <strong>without requiring any existing tools to change, but provides more granular information to users</strong>.</p> +<p>The format is simple:</p> +<div> + <code>{<span>EPOCH</span> * 100 + <span>MAJOR</span>}.<span>MINOR</span>.<span>PATCH</span></code> +</div> +<ul> +<li><span>EPOCH</span>: Increment when you make significant or groundbreaking changes.</li> +<li><span>MAJOR</span>: Increment when you make incompatible API changes.</li> +<li><span>MINOR</span>: Increment when you add functionality in a backwards-compatible manner.</li> +<li><span>PATCH</span>: Increment when you make backwards-compatible bug fixes.</li> +</ul> +<p>For example, UnoCSS would transition from <code>v0.65.3</code> to <code>v65.3.0</code>. Following SemVer, a patch release would become <code>v65.3.1</code>, and a feature release would be <code>v65.4.0</code>. If we introduced some minor incompatible changes affecting an edge case, we could bump it to <code>v66.0.0</code> to alert users of potential impacts. In the event of a significant overhaul to the core, we could jump directly to <code>v100.0.0</code> to signal a new era and make a big announcement. This approach provides maintainers with more flexibility to communicate the scale of changes to users effectively.</p> +<p>Of course, I'm not suggesting that everyone should adopt this approach. It's simply an idea to work around the existing system. It will be interesting to see how it performs in practice.</p> +<h2>Moving Forward</h2> +<p>I plan to adopt Epoch Semantic Versioning in my projects, including UnoCSS, Slidev, and all the plugins I maintain. I hope this new versioning approach will help communicate changes more effectively and provide users with better context when upgrading.</p> +<p>I'd love to hear your thoughts and feedback on this idea. Feel free to share your comments using the links below!</p>`, guid: "1x-3031649", author: "Seray AK", authorUrl: null, diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index 443800d94f..fb31c7fd62 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -2,10 +2,11 @@ import type { AVPlaybackStatus } from "expo-av" import { Video } from "expo-av" import { Stack, useLocalSearchParams } from "expo-router" import { useState } from "react" -import { Dimensions, Text, View } from "react-native" +import { Dimensions, ScrollView, Text, View } from "react-native" import PagerView from "react-native-pager-view" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import HtmlWeb from "@/src/components/ui/typography/HtmlWeb" import { useShouldAnimate } from "@/src/modules/entry/ctx" import { DATA } from "@/src/modules/entry/data" @@ -34,7 +35,7 @@ function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) onPlaybackStatusUpdate={(status) => setStatus(() => status)} /> )} - <View className="bg-gray-6 flex-1 justify-center"> + <View className="flex-1 justify-center"> {imageUrl ? ( <ReAnimatedExpoImage source={{ uri: imageUrl }} @@ -77,11 +78,12 @@ export default function EntryDetailPage() { const shouldAnimate = useShouldAnimate() const [currentPageIndex, setCurrentPageIndex] = useState(0) + const [height, setHeight] = useState(0) return ( <> <Stack.Screen options={{ animation: "fade", animationDuration: 300 }} /> - <View className="flex-1 p-safe"> + <ScrollView className="pt-safe" contentContainerClassName="flex-grow"> <View style={{ height: maxMediaHeight > 0 ? maxMediaHeight : "80%", @@ -104,7 +106,7 @@ export default function EntryDetailPage() { </PagerView> )} {mediaList.length > 1 && ( - <View className="mt-2 w-full flex-row items-center justify-center gap-2"> + <View className="my-2 w-full flex-row items-center justify-center gap-2"> {Array.from({ length: mediaList.length }).map((_, index) => { return ( <View @@ -118,7 +120,19 @@ export default function EntryDetailPage() { </View> )} </View> - </View> + <HtmlWeb + content={item.entries.content || ""} + onLayout={async (size) => { + if (size[1] !== height) { + setHeight(size[1]) + } + }} + dom={{ + scrollEnabled: false, + style: { height }, + }} + /> + </ScrollView> </> ) } diff --git a/packages/components/src/ui/markdown/html.tsx b/packages/components/src/ui/markdown/html.tsx index 285eb77da1..263e03d9ff 100644 --- a/packages/components/src/ui/markdown/html.tsx +++ b/packages/components/src/ui/markdown/html.tsx @@ -14,7 +14,7 @@ export function Html({ content, ...options }: HtmlProps) { const res = parseHtml(content, options) return ( - <article className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"> + <article className="prose !max-w-full px-2 dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"> {res.toContent()} </article> ) From e4b2568e29d2df1170b45dd47c14aa68a3538941 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:05:51 +0800 Subject: [PATCH 05/11] chore: update --- apps/mobile/src/components/common/AnimatedComponents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/common/AnimatedComponents.tsx b/apps/mobile/src/components/common/AnimatedComponents.tsx index 3c257b3722..6da5b5c479 100644 --- a/apps/mobile/src/components/common/AnimatedComponents.tsx +++ b/apps/mobile/src/components/common/AnimatedComponents.tsx @@ -2,11 +2,11 @@ import { Image as ExpoImage } from "expo-image" import { Animated, FlatList, Pressable, ScrollView, TouchableOpacity } from "react-native" import Reanimated from "react-native-reanimated" -export const ReAnimatedExpoImage = ReAnimated.createAnimatedComponent(ExpoImage) export const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView) export const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) +export const ReAnimatedExpoImage = Reanimated.createAnimatedComponent(ExpoImage) export const ReAnimatedPressable = Reanimated.createAnimatedComponent(Pressable) export const ReAnimatedScrollView = Reanimated.createAnimatedComponent(ScrollView) export const ReAnimatedTouchableOpacity = Reanimated.createAnimatedComponent(TouchableOpacity) From edb5cbb50543cb529bf19ee77ce06f6ed602c65d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:17:38 +0800 Subject: [PATCH 06/11] type check --- .../mobile/src/components/ui/typography/HtmlWeb.tsx | 2 +- .../screens/(headless)/entries/[entryId]/index.tsx | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx index 4fd5ee8262..be5c0bb14a 100644 --- a/apps/mobile/src/components/ui/typography/HtmlWeb.tsx +++ b/apps/mobile/src/components/ui/typography/HtmlWeb.tsx @@ -8,7 +8,7 @@ import { useEffect } from "react" function useSize(callback: (size: [number, number]) => void) { useEffect(() => { - const lastSize = [document.body.clientWidth, document.body.clientHeight] + const lastSize = [document.body.clientWidth, document.body.clientHeight] as [number, number] // Observe window size changes const observer = new ResizeObserver((entries) => { diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index fb31c7fd62..b96cf0f12f 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -58,13 +58,14 @@ function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) export default function EntryDetailPage() { const { entryId } = useLocalSearchParams() const initialIndex = DATA.findIndex((item) => item.entries.id === entryId) - const item = DATA[initialIndex] + const item = DATA[initialIndex]! - const mediaList = item?.entries.media - .filter((media) => media.url) - .filter((media, index) => { - return item.entries.media.findIndex((m) => m.url === media.url) === index - }) + const mediaList = + item?.entries.media + .filter((media) => media.url) + .filter((media, index) => { + return item.entries.media.findIndex((m) => m.url === media.url) === index + }) || [] const windowWidth = Dimensions.get("window").width const maxMediaHeight = Math.max( From 88948405c47f14f647d64cd0b111539787fe4cd2 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 20 Jan 2025 12:41:34 +0800 Subject: [PATCH 07/11] load entry data --- apps/mobile/src/database/index.ts | 2 +- apps/mobile/src/modules/entry/data.ts | 1316 ----------------- apps/mobile/src/modules/entry/gird.tsx | 116 +- .../(headless)/entries/[entryId]/index.tsx | 17 +- .../screens/(stack)/feeds/[feedId]/index.tsx | 52 +- apps/mobile/src/store/entry/store.ts | 19 +- 6 files changed, 113 insertions(+), 1409 deletions(-) delete mode 100644 apps/mobile/src/modules/entry/data.ts diff --git a/apps/mobile/src/database/index.ts b/apps/mobile/src/database/index.ts index cef2d2206d..355b95059d 100644 --- a/apps/mobile/src/database/index.ts +++ b/apps/mobile/src/database/index.ts @@ -14,7 +14,7 @@ let db: ExpoSQLiteDatabase<typeof schema> & { export function initializeDb() { db = drizzle(sqlite, { schema, - logger: true, + // logger: true, }) } diff --git a/apps/mobile/src/modules/entry/data.ts b/apps/mobile/src/modules/entry/data.ts deleted file mode 100644 index 7b541fa20d..0000000000 --- a/apps/mobile/src/modules/entry/data.ts +++ /dev/null @@ -1,1316 +0,0 @@ -export const DATA = [ - { - read: true, - view: 2, - entries: { - id: "99090899709506560", - title: "Color of Emotions", - url: "https://1x.com/photo/3031649", - description: "Color of Emotions by Seray AK", - content: `<p>If you've been following my work in open source, you might have noticed that I have a tendency to stick with zero-major versioning, like <code>v0.x.x</code>. For instance, as of writing this post, the latest version of UnoCSS is <a href="https://github.com/unocss/unocss/releases/tag/v0.65.3" target="_blank"><code>v0.65.3</code></a>, Slidev is <a href="https://github.com/slidevjs/slidev/releases/tag/v0.50.0" target="_blank"><code>v0.50.0</code></a>, and <code>unplugin-vue-components</code> is <a href="https://github.com/unplugin/unplugin-vue-components/releases/tag/v0.28.0" target="_blank"><code>v0.28.0</code></a>. Other projects, such as React Native is on <a href="https://github.com/facebook/react-native/releases/tag/v0.76.5" target="_blank"><code>v0.76.5</code></a>, and sharp is on <a href="https://github.com/lovell/sharp/releases/tag/v0.33.5" target="_blank"><code>v0.33.5</code></a>, also follow this pattern.</p> -<p>People often assume that a zero-major version indicates that the software is not ready for production. However, all of the projects mentioned here are quite stable and production-ready, used by millions of projects.</p> -<p><strong>Why?</strong> - I bet that's your question reading this.</p> -<h2>Versioning</h2> -<p>Version numbers act as snapshots of our codebase, helping us communicate changes effectively. For instance, we can say "it works in v1.3.2, but not in v1.3.3, there might be a regression." This makes it easier for maintainers to locate bugs by comparing the differences between these versions. A version is essentially a marker, a seal of the codebase at a specific point in time.</p> -<p>However, code is complex, and every change involves trade-offs. Describing how a change affects the code can be tricky, even with natural language. A version number alone can't capture all the nuances of a release. That's why we have changelogs, release notes, and commit messages to provide more context.</p> -<p>I see versioning as a way to communicate changes to users — a <strong>contract</strong> between the library and its users to ensure compatibility and stability during upgrades. As a user, you can't always tell what's changed between <code>v2.3.4</code> and <code>v2.3.5</code> without checking the changelog. But by looking at the numbers, you can infer that it's a patch release meant to fix bugs, which should be safe to upgrade. This ability to understand changes just by looking at the version number is possible because both the library maintainer and the users agree on the versioning scheme.</p> -<p>Since versioning is only a contract, you shouldn't blindly trust it. It serves as an indication to help you decide when to take a closer look at the changelog and be cautious about upgrading. But it's not a guarantee that everything will work as expected, every change might introduce behavior changes whether it's intended or not.</p> -<h2>Semantic Versioning</h2> -<p>In the JavaScript ecosystem, especially for packages published on npm, we follow a convention known as <a href="https://semver.org/" target="_blank">Semantic Versioning</a>, or SemVer for short. A SemVer version number consists of three parts: <code>MAJOR.MINOR.PATCH</code>. The rules are straightforward:</p> -<ul> -<li><strong>MAJOR</strong>: Increment when you make incompatible API changes.</li> -<li><strong>MINOR</strong>: Increment when you add functionality in a backwards-compatible manner.</li> -<li><strong>PATCH</strong>: Increment when you make backwards-compatible bug fixes.</li> -</ul> -<p>Package managers we use, like <code>npm</code>, <code>pnpm</code>, and <code>yarn</code>, all operate under the assumption that every package on npm adheres to SemVer. When you or a package specifies a dependency with a version range, such as <code>^1.2.3</code>, it indicates that you are comfortable with upgrading to any version that shares the same major version (<code>1.x.x</code>). In these scenarios, package managers will automatically determine the best version to install based on what is most suitable for your specific project.</p> -<p>This convention works well technically. If a package releases a new major version <code>v2.0.0</code>, your package manager won't install it if your specified range is <code>^1.2.3</code>. This prevents unexpected breaking changes from affecting your project until you manually update the version range.</p> -<p>Humans perceive numbers on a logarithmic scale. We tend to see <code>v2.0</code> to <code>v3.0</code> as a huge, groundbreaking change, while <code>v125.0</code> to <code>v126.0</code> seems trivial, even though both indicate incompatible API changes in SemVer. This perception can make maintainers hesitant to bump the major version for minor breaking changes, leading to the accumulation of many breaking changes in a single major release, making upgrades harder for users. Conversely, with something like <code>v125.0</code>, it becomes difficult to convey the significance of a major change, as the jump to <code>v126.0</code> appears minor.</p> -<h2>Progressive</h2> -<p>I strongly believe in the principle of progressiveness. Rather than making a giant leap to a significantly higher stage all at once, progressiveness allows users to adopt changes gradually at their own pace. It provides opportunities to pause and assess, making it easier to understand the impact of each change.</p> -<figure> - <img src="https://antfu.me/images/epoch-semver-progressive-1.png" alt="Progressive as Stairs" border="~ base rounded-xl"> - Progressive as Stairs - a screenshot of my talk <a href="/talks#the-progressive-path" target="_blank">The Progressive Path</a> -</figure> -<p>I believe we should apply the same principle to versioning. Instead of treating a major version as a massive overhaul, we can break it down into smaller, more manageable updates. For example, rather than releasing <code>v2.0.0</code> with 10 breaking changes from <code>v1.x</code>, we could distribute these changes across several smaller major releases. This way, we might release <code>v2.0</code> with 2 breaking changes, followed by <code>v3.0</code> with 1 breaking change, and so on. This approach makes it easier for users to adopt changes gradually and reduces the risk of overwhelming them with too many changes at once.</p> -<figure> - <img src="https://antfu.me/images/epoch-semver-progressive-2.png" alt="Progressive on Breaking Changes" border="~ base rounded-xl"> - Progressive on Breaking Changes - a screenshot of my talk <a href="/talks#the-progressive-path" target="_blank">The Progressive Path</a> -</figure> -<h2>Leading Zero Major Versioning</h2> -<p>The reason I've stuck with <code>v0.x.x</code> is my own unconventional approach to versioning. I prefer to introduce necessary and minor breaking changes early on, making upgrades easier, without causing alarm that typically comes with major version jumps like <code>v2</code> to <code>v3</code>. Some changes might be "technically" breaking but don't impact 99.9% of users in practice. Breaking changes are relative; even a bug fix can be breaking for those relying on the previous behavior (but that's another topic for discussion :P). There's a special rule in SemVer that states <strong>when the leading major version is <code>0</code>, every minor version bump is considered breaking</strong>. I've been leveraging this rule to navigate the system more flexibly. I kinda abuse that rule to workaround the limitation of SemVer.</p> -<p>Of course, zero-major versioning is not the only solution to be progressive. We can see that tools like <a href="https://nodejs.org/en" target="_blank">Node.js</a>, <a href="https://vite.dev/" target="_blank">Vite</a>, <a href="https://vitest.dev/" target="_blank">Vitest</a> are rolling out major versions in consistent intervals, with a minimal set of breaking changes in each release that are easy to adopt.</p> -<p>I have to admit that sticking to zero-major versioning isn't the best practice. While I aimed for more granular versioning to improve communication, using zero-major versioning has actually limited my ability to convey changes effectively. In reality, I've been wasting a valuable part of the versioning scheme due to my peculiar insistence.</p> -<p>Thus here, I am proposing to change.</p> -<h2>Epoch Semantic Versioning</h2> -<p><a href="https://x.com/antfu7/status/1679184417930059777" target="_blank">In an ideal world, I would wish SemVer to have four numbers: <code>EPOCH.MAJOR.MINOR.PATCH</code></a>. The <code>EPOCH</code> version is for those big announcements, while <code>MAJOR</code> is for technical incompatible API changes that might not be significant. This way, we can have a more granular way to communicate changes. Similar we also have <a href="https://github.com/romversioning/romver" target="_blank">Romantic Versioning that propose <code>HUMAN.MAJOR.MINOR</code></a>. But of course, it's too late for the entire ecosystem to adopt a new versioning scheme.</p> -<p>If we can't change SemVer, maybe we can at least extend it. I am proposing a new versioning scheme called <strong>Epoch Semantic Versioning</strong> (Epoch SemVer for short). Build on top of the structure of <code>MAJOR.MINOR.PATCH</code>, extend the first number to be the combination of <code>EPOCH</code> and <code>MAJOR</code>. To put a difference between them, we use a third digit to represent <code>EPOCH</code>, which gives <code>MAJOR</code> a range from 0 to 99. This way, it follows the exact same rules as SemVer <strong>without requiring any existing tools to change, but provides more granular information to users</strong>.</p> -<p>The format is simple:</p> -<div> - <code>{<span>EPOCH</span> * 100 + <span>MAJOR</span>}.<span>MINOR</span>.<span>PATCH</span></code> -</div> -<ul> -<li><span>EPOCH</span>: Increment when you make significant or groundbreaking changes.</li> -<li><span>MAJOR</span>: Increment when you make incompatible API changes.</li> -<li><span>MINOR</span>: Increment when you add functionality in a backwards-compatible manner.</li> -<li><span>PATCH</span>: Increment when you make backwards-compatible bug fixes.</li> -</ul> -<p>For example, UnoCSS would transition from <code>v0.65.3</code> to <code>v65.3.0</code>. Following SemVer, a patch release would become <code>v65.3.1</code>, and a feature release would be <code>v65.4.0</code>. If we introduced some minor incompatible changes affecting an edge case, we could bump it to <code>v66.0.0</code> to alert users of potential impacts. In the event of a significant overhaul to the core, we could jump directly to <code>v100.0.0</code> to signal a new era and make a big announcement. This approach provides maintainers with more flexibility to communicate the scale of changes to users effectively.</p> -<p>Of course, I'm not suggesting that everyone should adopt this approach. It's simply an idea to work around the existing system. It will be interesting to see how it performs in practice.</p> -<h2>Moving Forward</h2> -<p>I plan to adopt Epoch Semantic Versioning in my projects, including UnoCSS, Slidev, and all the plugins I maintain. I hope this new versioning approach will help communicate changes more effectively and provide users with better context when upgrading.</p> -<p>I'd love to hear your thoughts and feedback on this idea. Feel free to share your comments using the links below!</p>`, - guid: "1x-3031649", - author: "Seray AK", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T13:55:22.089Z", - publishedAt: "2025-01-06T13:55:22.089Z", - media: [ - { - url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LZL3*-IA?ZxZ~VV@tRxsIVt7j[WV", - }, - // { - // url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", - // type: "photo", - // width: 2500, - // height: 1667, - // blurhash: "LZL3*-IA?ZxZ~VV@tRxsIVt7j[WV", - // }, - { - url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", - type: "photo", - width: 2500, - height: 1875, - blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/c7b8fa8511d4a2b875b382fe1b94d441-hd4.jpg", - title: "Color of Emotions", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99090899709506561", - title: "The Alps", - url: "https://1x.com/photo/3031271", - description: "The Alps by Ricarda V", - guid: "1x-3031271", - author: "Ricarda V", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T13:55:22.088Z", - publishedAt: "2025-01-06T13:55:22.088Z", - media: [ - // { - // url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", - // type: "photo", - // width: 1333, - // height: 2000, - // blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", - // }, - // { - // url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", - // type: "photo", - // width: 1333, - // height: 2000, - // blurhash: "L#J8Ib%LRjay~ot7WBj[xuR+j[s:", - // }, - { - url: "https://video.twimg.com/amplify_video/1848739714049908736/vid/avc1/1280x720/jzWeEF8Xd3WmYp5s.mp4?tag=16", - type: "video", - width: 1280, - height: 720, - preview_image_url: - "https://pbs.twimg.com/amplify_video_thumb/1848739714049908736/img/OJbHuDJHbYcA6zzW.jpg", - }, - // { - // url: "https://media.st.dl.eccdnx.com/steam/apps/2074800/extras/pic1.png?t=1673603154", - // type: "photo", - // width: 616, - // height: 350, - // blurhash: "LLLW6Yg7xzM.?aj2+c$,t*R*tPxV", - // }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/3ea3c266ed77aaa93a6a3a6221de853d-hd4.jpg", - title: "The Alps", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - // { - // read: true, - // view: 2, - // entries: { - // id: "99090899709506562", - // title: "big face", - // url: "https://1x.com/photo/3031638", - // description: "big face by miyamoto", - // guid: "1x-3031638", - // author: "miyamoto", - // authorUrl: null, - // authorAvatar: null, - // insertedAt: "2025-01-06T13:55:22.087Z", - // publishedAt: "2025-01-06T13:55:22.087Z", - // media: [ - // { - // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", - // type: "photo", - // width: 2500, - // height: 1875, - // blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", - // }, - // { - // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", - // type: "photo", - // width: 2500, - // height: 1875, - // blurhash: "LTH-rbR-axxZ0LRQs:R+%ftQWBni", - // }, - // ], - // categories: null, - // attachments: [ - // { - // url: "https://1x.com/images/user/523d47fa1f9d54e407077007c07309ec-hd4.jpg", - // title: "big face", - // mime_type: "image/jpg", - // }, - // ], - // extra: null, - // language: null, - // }, - // feeds: { - // type: "feed", - // id: "41375451836487680", - // url: "rsshub://1x/latest/awarded", - // title: "1x.com • In Pursuit of the Sublime", - // description: - // "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - // siteUrl: "https://1x.com/gallery/latest/awarded", - // image: "https://1x.com/assets/img/1x-logo-1.png", - // errorMessage: null, - // errorAt: null, - // ownerUserId: null, - // }, - // collections: null, - // subscriptions: { - // category: null, - // }, - // settings: { - // silence: true, - // }, - // }, - { - read: true, - view: 2, - entries: { - id: "99090899709506563", - title: "Through the Shadow", - url: "https://1x.com/photo/3031146", - description: "Through the Shadow by MingLun Tsai", - guid: "1x-3031146", - author: "MingLun Tsai", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T13:55:22.086Z", - publishedAt: "2025-01-06T13:55:22.086Z", - media: [ - { - url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", - type: "photo", - width: 1332, - height: 2000, - blurhash: "LHC?r]_3-;%M~qxuj[WB9FM{M{WB", - }, - { - url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", - type: "photo", - width: 1332, - height: 2000, - blurhash: "LHC?r]_3-;%M~qxuj[WB9FM{M{WB", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/3f4ff7702c56310aa78633fa701defcb-hd4.jpg", - title: "Through the Shadow", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99090899709506564", - title: "Fanal forest.", - url: "https://1x.com/photo/3028102", - description: "Fanal forest. by Milosz Wilczynski", - guid: "1x-3028102", - author: "Milosz Wilczynski", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T13:55:22.085Z", - publishedAt: "2025-01-06T13:55:22.085Z", - media: [ - { - url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", - type: "photo", - width: 2500, - height: 1578, - blurhash: "LlL;me~qM{t7?b%MWBofD%Rjt7t7", - }, - { - url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", - type: "photo", - width: 2500, - height: 1578, - blurhash: "LlL;me~qM{t7?b%MWBofD%Rjt7t7", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/cc53d8985765bac2f41d07f0c72f782d-hd2.jpg", - title: "Fanal forest.", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99090899709506565", - title: "White Stage", - url: "https://1x.com/photo/3031020", - description: "White Stage by Michiko Ôtomo", - guid: "1x-3031020", - author: "Michiko Ôtomo", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T13:55:22.084Z", - publishedAt: "2025-01-06T13:55:22.084Z", - media: [ - { - url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LTECwd%MD%Rj00WBt7WBofRjofof", - }, - { - url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LTECwd%MD%Rj00WBt7WBofRjofof", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/371ece69b4d64edfcbf12499cb03b7da-hd4.jpg", - title: "White Stage", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99073011860296704", - title: "Echoes of Stillness", - url: "https://1x.com/photo/3031225", - description: "Echoes of Stillness by Mary Cheng", - guid: "1x-3031225", - author: "Mary Cheng", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T12:44:21.589Z", - publishedAt: "2025-01-06T12:44:21.589Z", - media: [ - { - url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", - type: "photo", - width: 2500, - height: 1629, - blurhash: "LHHLl1IU-;j[D%xuj[fQ~qxuj[Rj", - }, - { - url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", - type: "photo", - width: 2500, - height: 1629, - blurhash: "LHHLl1IU-;j[D%xuj[fQ~qxuj[Rj", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/ecc9c37a9aefe48e1d29e68c8cd67496-hd4.jpg", - title: "Echoes of Stillness", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99073011860296705", - title: "Praise of symmetry", - url: "https://1x.com/photo/3031206", - description: "Praise of symmetry by Martin Kucera AFIAP AZSF", - guid: "1x-3031206", - author: "Martin Kucera AFIAP AZSF", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T12:44:21.588Z", - publishedAt: "2025-01-06T12:44:21.588Z", - media: [ - { - url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "L6AA{.tR?w%gtRjbj]jb?wkBoMof", - }, - { - url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "L6AA{.tR?w%gtRjbj]jb?wkBoMof", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/e9e843cff84d629c423d226781975072-hd4.jpg", - title: "Praise of symmetry", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99073011860296706", - title: "Pas de visage", - url: "https://1x.com/photo/3026370", - description: "Pas de visage by Kurosaki Sangan", - guid: "1x-3026370", - author: "Kurosaki Sangan", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T12:44:21.587Z", - publishedAt: "2025-01-06T12:44:21.587Z", - media: [ - { - url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", - type: "photo", - width: 1334, - height: 2000, - blurhash: "LAAc_C~qXTxaaKRjRjf6S$ofM{IU", - }, - { - url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", - type: "photo", - width: 1334, - height: 2000, - blurhash: "LAAc_C~qXTxaaKRjRjf6S$ofM{IU", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/142b9feecb335be6b7c64162428e741c-hd2.jpg", - title: "Pas de visage", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99073011860296707", - title: "Double-helix staircase", - url: "https://1x.com/photo/3031616", - description: "Double-helix staircase by konglingming", - guid: "1x-3031616", - author: "konglingming", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T12:44:21.586Z", - publishedAt: "2025-01-06T12:44:21.586Z", - media: [ - { - url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LEETVs9bOR$MNNt8e,wb1Ixt;NxD", - }, - { - url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LEETVs9bOR$MNNt8e,wb1Ixt;NxD", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/cef4db9e07d48f7e16c527da5e9c7f6e-hd2.jpg", - title: "Double-helix staircase", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99073011860296708", - title: "Old / New", - url: "https://1x.com/photo/3031195", - description: "Old / New by Jürgen Muß", - guid: "1x-3031195", - author: "Jürgen Muß", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T12:44:21.585Z", - publishedAt: "2025-01-06T12:44:21.585Z", - media: [ - { - url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LCAAgu8^IUV@M|kCt7of4TtR%Na}", - }, - { - url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", - type: "photo", - width: 2500, - height: 1667, - blurhash: "LCAAgu8^IUV@M|kCt7of4TtR%Na}", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/ceaa3dbf6535b9bada43eb9e2e6a9fba-hd4.jpg", - title: "Old / New", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752896", - title: "Hong Kong Cityscape", - url: "https://1x.com/photo/3026523", - description: "Hong Kong Cityscape by JUNGJAEYONG", - guid: "1x-3026523", - author: "JUNGJAEYONG", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.398Z", - publishedAt: "2025-01-06T11:33:18.398Z", - media: [ - { - url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", - type: "photo", - width: 1331, - height: 2000, - blurhash: "LA9Qmq%M00-;of-;%MRj4nt7~q%M", - }, - { - url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", - type: "photo", - width: 1331, - height: 2000, - blurhash: "LA9Qmq%M00-;of-;%MRj4nt7~q%M", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/c749413e1f7b12fda2dad825da499be1-hd2.jpg", - title: "Hong Kong Cityscape", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752897", - title: "Salt Lives #93", - url: "https://1x.com/photo/3009775", - description: "Salt Lives #93 by Josefina Melo", - guid: "1x-3009775", - author: "Josefina Melo", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.397Z", - publishedAt: "2025-01-06T11:33:18.397Z", - media: [ - { - url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "LUC%8J~q-;xu-;-;xuofD%M{RjWB", - }, - { - url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "LUC%8J~q-;xu-;-;xuofD%M{RjWB", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/55ed80803d64d9eb8077f6cd30a12cfc-hd2.jpg", - title: "Salt Lives #93", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752898", - title: "Dockland Office Building", - url: "https://1x.com/photo/3031261", - description: "Dockland Office Building by jordiegeatorrent", - guid: "1x-3031261", - author: "jordiegeatorrent", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.396Z", - publishedAt: "2025-01-06T11:33:18.396Z", - media: [ - { - url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", - type: "photo", - width: 2500, - height: 1872, - blurhash: "LE8;V?IUM{j[M{t7M{fQ00t7xuay", - }, - { - url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", - type: "photo", - width: 2500, - height: 1872, - blurhash: "LE8;V?IUM{j[M{t7M{fQ00t7xuay", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/5408da5e297030c53d8882d6235a0a79-hd4.jpg", - title: "Dockland Office Building", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752899", - title: "WAY OF LIGHT", - url: "https://1x.com/photo/3031265", - description: "WAY OF LIGHT by Jesus Concepcion Alvarado", - guid: "1x-3031265", - author: "Jesus Concepcion Alvarado", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.395Z", - publishedAt: "2025-01-06T11:33:18.395Z", - media: [ - { - url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", - type: "photo", - width: 2500, - height: 1668, - blurhash: "LI9Qgg?b%Lbb_4%Mt6WqNhNKR-fS", - }, - { - url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", - type: "photo", - width: 2500, - height: 1668, - blurhash: "LI9Qgg?b%Lbb_4%Mt6WqNhNKR-fS", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/f067f2bdc6b37a97f2640537dcb5e995-hd4.jpg", - title: "WAY OF LIGHT", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752900", - title: "Christmas market, Bocholt Germany", - url: "https://1x.com/photo/3030503", - description: "Christmas market, Bocholt Germany by Jan van der Linden", - guid: "1x-3030503", - author: "Jan van der Linden", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.394Z", - publishedAt: "2025-01-06T11:33:18.394Z", - media: [ - { - url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", - type: "photo", - width: 2500, - height: 1669, - blurhash: "LZFhIqbuI:$%}@X7I;xF$jjFNHWB", - }, - { - url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", - type: "photo", - width: 2500, - height: 1669, - blurhash: "LZFhIqbuI:$%}@X7I;xF$jjFNHWB", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/d763427b6b845d2d5688f35aef707ad9-hd4.jpg", - title: "Christmas market, Bocholt Germany", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99055140412752901", - title: "New World Center Entrance", - url: "https://1x.com/photo/3031617", - description: "New World Center Entrance by Ivan Huang", - guid: "1x-3031617", - author: "Ivan Huang", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T11:33:18.393Z", - publishedAt: "2025-01-06T11:33:18.393Z", - media: [ - { - url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", - type: "photo", - width: 2500, - height: 1063, - blurhash: "LMAAaaIUofIUt7M{%MRj00t7xuxu", - }, - { - url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", - type: "photo", - width: 2500, - height: 1063, - blurhash: "LMAAaaIUofIUt7M{%MRj00t7xuxu", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/7edb209295a115923f19c493bfef3db1-hd4.jpg", - title: "New World Center Entrance", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99037256169255936", - title: "Evolution", - url: "https://1x.com/photo/3031278", - description: "Evolution by Giorgio Toniolo", - guid: "1x-3031278", - author: "Giorgio Toniolo", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T10:22:16.859Z", - publishedAt: "2025-01-06T10:22:16.859Z", - media: [ - { - url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", - type: "photo", - width: 1845, - height: 2500, - blurhash: "L38qNg0000%MD%ay-;D%xuxuIUIU", - }, - { - url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", - type: "photo", - width: 1845, - height: 2500, - blurhash: "L38qNg0000%MD%ay-;D%xuxuIUIU", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/5319d3402e8803b2af33f37eb5f9dbdb-hd2.jpg", - title: "Evolution", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99037256169255937", - title: "Abounded scooter", - url: "https://1x.com/photo/3030870", - description: "Abounded scooter by Gilad Topaz", - guid: "1x-3030870", - author: "Gilad Topaz", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T10:22:16.858Z", - publishedAt: "2025-01-06T10:22:16.858Z", - media: [ - { - url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", - type: "photo", - width: 1600, - height: 2000, - blurhash: "LDB.}|X#0_m.RhbZI.aPx[wgogOS", - }, - { - url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", - type: "photo", - width: 1600, - height: 2000, - blurhash: "LDB.}|X#0_m.RhbZI.aPx[wgogOS", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/e1fce2761860d996e7db3367dcd656a7-hd2.jpg", - title: "Abounded scooter", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, - { - read: true, - view: 2, - entries: { - id: "99037256169255938", - title: "***", - url: "https://1x.com/photo/3029819", - description: "*** by Eleonora Fridman", - guid: "1x-3029819", - author: "Eleonora Fridman", - authorUrl: null, - authorAvatar: null, - insertedAt: "2025-01-06T10:22:16.857Z", - publishedAt: "2025-01-06T10:22:16.857Z", - media: [ - { - url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "LBIN,M~p5Qw]-B?GI.I:I:oNn+NH", - }, - { - url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", - type: "photo", - width: 2000, - height: 2000, - blurhash: "LBIN,M~p5Qw]-B?GI.I:I:oNn+NH", - }, - ], - categories: null, - attachments: [ - { - url: "https://1x.com/images/user/fe9757360207e474d68150462d75bbc5-hd2.jpg", - title: "***", - mime_type: "image/jpg", - }, - ], - extra: null, - language: null, - }, - feeds: { - type: "feed", - id: "41375451836487680", - url: "rsshub://1x/latest/awarded", - title: "1x.com • In Pursuit of the Sublime", - description: - "1x.com is the world's biggest curated photo gallery online. Each photo is selected by professional curators. 1x.com • In Pursuit of the Sublime - Powered by RSSHub", - siteUrl: "https://1x.com/gallery/latest/awarded", - image: "https://1x.com/assets/img/1x-logo-1.png", - errorMessage: null, - errorAt: null, - ownerUserId: null, - }, - collections: null, - subscriptions: { - category: null, - }, - settings: { - silence: true, - }, - }, -] diff --git a/apps/mobile/src/modules/entry/gird.tsx b/apps/mobile/src/modules/entry/gird.tsx index b56764038a..e46082e219 100644 --- a/apps/mobile/src/modules/entry/gird.tsx +++ b/apps/mobile/src/modules/entry/gird.tsx @@ -5,72 +5,70 @@ import { SharedTransition } from "react-native-reanimated" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" import { ThemedText } from "@/src/components/common/ThemedText" - -import { DATA } from "./data" +import { useEntry } from "@/src/store/entry/hooks" const transition = SharedTransition.duration(300) -type EntryList = Array<{ - entries: { - id: string - title: string - media: Array<{ - type: string - url: string - width?: number - height?: number - preview_image_url?: string - }> +export function EntryColumnGrid({ + entryIds, + onEndReached, +}: { + entryIds: string[] + onEndReached?: () => void +}) { + return ( + <MasonryFlashList + data={entryIds} + numColumns={2} + contentInsetAdjustmentBehavior="automatic" + contentContainerClassName="p-1" + renderItem={({ item }) => { + return <RenderEntryItem id={item} /> + }} + onEndReached={onEndReached} + /> + ) +} +function RenderEntryItem({ id }: { id: string }) { + const item = useEntry(id) + if (!item) { + return null } -}> + const photo = item.media?.find((media) => media.type === "photo") + const video = item.media?.find((media) => media.type === "video") + const imageUrl = photo?.url || video?.preview_image_url + const aspectRatio = + photo?.height && photo.width + ? photo.width / photo.height + : video?.height && video.width + ? video.width / video.height + : 16 / 9 -export function EntryColumnGrid() { return ( - <View className="flex-1 flex-row bg-gray-50"> - <MasonryFlashList - data={DATA as EntryList} - numColumns={2} - keyExtractor={(item) => item.entries.id} - contentContainerClassName="p-1" - renderItem={({ item }) => { - const photo = item.entries.media.find((media) => media.type === "photo") - const video = item.entries.media.find((media) => media.type === "video") - const imageUrl = photo?.url || video?.preview_image_url - const aspectRatio = - photo?.height && photo.width - ? photo.width / photo.height - : video?.height && video.width - ? video.width / video.height - : 16 / 9 - - return ( - <View className="m-1 overflow-hidden rounded-md bg-white"> - <Link href={`/entries/${item.entries.id}`} asChild> - <Pressable> - {imageUrl ? ( - <ReAnimatedExpoImage - source={{ uri: imageUrl }} - style={{ - width: "100%", - aspectRatio, - }} - sharedTransitionTag={`entry-image-${imageUrl}`} - sharedTransitionStyle={transition} - allowDownscaling={false} - /> - ) : ( - <View className="aspect-video w-full items-center justify-center"> - <ThemedText className="text-center">No media available</ThemedText> - </View> - )} - </Pressable> - </Link> - - <ThemedText className="p-2">{item.entries.title}</ThemedText> + <View className="m-1 overflow-hidden rounded-md bg-white"> + <Link href={`/entries/${item.id}`} asChild> + <Pressable> + {imageUrl ? ( + <ReAnimatedExpoImage + source={{ uri: imageUrl }} + style={{ + width: "100%", + aspectRatio, + }} + sharedTransitionTag={`entry-image-${imageUrl}`} + sharedTransitionStyle={transition} + allowDownscaling={false} + recyclingKey={imageUrl} + /> + ) : ( + <View className="aspect-video w-full items-center justify-center"> + <ThemedText className="text-center">No media available</ThemedText> </View> - ) - }} - /> + )} + </Pressable> + </Link> + + <ThemedText className="p-2">{item.title}</ThemedText> </View> ) } diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index b96cf0f12f..1ab4a6a3d4 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -8,7 +8,7 @@ import PagerView from "react-native-pager-view" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" import HtmlWeb from "@/src/components/ui/typography/HtmlWeb" import { useShouldAnimate } from "@/src/modules/entry/ctx" -import { DATA } from "@/src/modules/entry/data" +import { useEntry } from "@/src/store/entry/hooks" function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) { const isVideo = media.type === "video" @@ -57,14 +57,13 @@ function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) export default function EntryDetailPage() { const { entryId } = useLocalSearchParams() - const initialIndex = DATA.findIndex((item) => item.entries.id === entryId) - const item = DATA[initialIndex]! + const item = useEntry(entryId as string) const mediaList = - item?.entries.media - .filter((media) => media.url) + item?.media + ?.filter((media) => media.url) .filter((media, index) => { - return item.entries.media.findIndex((m) => m.url === media.url) === index + return item.media?.findIndex((m) => m.url === media.url) === index }) || [] const windowWidth = Dimensions.get("window").width @@ -72,7 +71,7 @@ export default function EntryDetailPage() { ...mediaList .filter((media) => media.height && media.width) .map((media) => { - return windowWidth * (media.height / media.width) + return windowWidth * (media.height! / media.width!) }), ) @@ -93,7 +92,7 @@ export default function EntryDetailPage() { > {mediaList.length > 0 && ( <PagerView - key={item.entries.id} + key={item?.id} style={{ flex: 1 }} initialPage={0} orientation="horizontal" @@ -122,7 +121,7 @@ export default function EntryDetailPage() { )} </View> <HtmlWeb - content={item.entries.content || ""} + content={item?.content || ""} onLayout={async (size) => { if (size[1] !== height) { setHeight(size[1]) diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx index ce1cd5532b..6ba20ff837 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx @@ -1,37 +1,49 @@ import { Stack, useLocalSearchParams } from "expo-router" -import { ScrollView, Text, View } from "react-native" +import { useState } from "react" +import { View } from "react-native" import { BlurEffect } from "@/src/components/common/HeaderBlur" +import { EntryColumnGrid } from "@/src/modules/entry/gird" +import { getEntry } from "@/src/store/entry/getter" +import { useEntryIdsByFeedId, usePrefetchEntries } from "@/src/store/entry/hooks" +import { useFeed } from "@/src/store/feed/hooks" -export default function Feed() { - const { feedId } = useLocalSearchParams() +function FeedEntryList({ feedId }: { feedId: string }) { + const [pageParam, setPageParam] = useState<string | undefined>() + usePrefetchEntries({ feedId, pageParam }) + const feed = useFeed(feedId) + const entryIds = useEntryIdsByFeedId(feedId) return ( - <View> + <View className="flex-1 flex-row bg-gray-50"> <Stack.Screen options={{ headerShown: true, headerBackTitle: "Subscriptions", headerBackground: BlurEffect, - headerTransparent: true, - headerTitle: "Feed", + headerTitle: feed?.title ?? "Feed", + }} + /> + <EntryColumnGrid + entryIds={entryIds} + onEndReached={() => { + const lastEntryId = entryIds.at(-1) + if (!lastEntryId) return + const lastEntry = getEntry(lastEntryId) + if (!lastEntry) return + setPageParam(lastEntry.publishedAt.toISOString()) }} /> - <ScrollView contentInsetAdjustmentBehavior="automatic" className="h-full"> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - <Text>Feed {feedId}</Text> - </ScrollView> </View> ) } + +export default function Feed() { + const { feedId } = useLocalSearchParams() + if (!feedId || Array.isArray(feedId)) { + return null + } + + return <FeedEntryList feedId={feedId} /> +} diff --git a/apps/mobile/src/store/entry/store.ts b/apps/mobile/src/store/entry/store.ts index 41a8d01b78..a6c14e0327 100644 --- a/apps/mobile/src/store/entry/store.ts +++ b/apps/mobile/src/store/entry/store.ts @@ -130,8 +130,19 @@ class EntryActions { await tx.run() } - reset() { - set(defaultState) + reset(entries: EntryModel[] = []) { + if (entries.length > 0) { + immerSet((draft) => { + // remove all entries from draft.data not in entries + for (const existingEntry of Object.values(draft.data)) { + if (!entries.some((e) => e.id === existingEntry.id)) { + delete draft.data[existingEntry.id] + } + } + }) + } else { + set(defaultState) + } } } @@ -154,11 +165,11 @@ class EntrySyncServices { }, }) + const entries = honoMorph.toEntry(res.data) if (!pageParam) { - entryActions.reset() + entryActions.reset(entries) } - const entries = honoMorph.toEntry(res.data) await entryActions.upsertMany(entries) if (params.listId) { await listActions.addEntryIds({ From 9281ec0b7cba6582287a1193b99f825991e9cd40 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:05:51 +0800 Subject: [PATCH 08/11] remove show animate logic --- apps/mobile/src/modules/entry/ctx.ts | 5 ---- .../mobile/src/screens/(headless)/_layout.tsx | 28 +++++-------------- .../(headless)/entries/[entryId]/index.tsx | 9 ++---- 3 files changed, 10 insertions(+), 32 deletions(-) delete mode 100644 apps/mobile/src/modules/entry/ctx.ts diff --git a/apps/mobile/src/modules/entry/ctx.ts b/apps/mobile/src/modules/entry/ctx.ts deleted file mode 100644 index 492fb0fceb..0000000000 --- a/apps/mobile/src/modules/entry/ctx.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext, useContext } from "react" - -const SharedElementAnimationContext = createContext<boolean>(true) -export const SharedElementAnimationContextProvider = SharedElementAnimationContext.Provider -export const useShouldAnimate = () => useContext(SharedElementAnimationContext) diff --git a/apps/mobile/src/screens/(headless)/_layout.tsx b/apps/mobile/src/screens/(headless)/_layout.tsx index 79d59a02dd..ed7a8a6b74 100644 --- a/apps/mobile/src/screens/(headless)/_layout.tsx +++ b/apps/mobile/src/screens/(headless)/_layout.tsx @@ -1,32 +1,18 @@ import { Stack } from "expo-router" -import { useState } from "react" import { useColorScheme } from "react-native" -import { SharedElementAnimationContextProvider } from "@/src/modules/entry/ctx" import { getSystemBackgroundColor } from "@/src/theme/utils" export default function HeadlessLayout() { useColorScheme() const systemBackgroundColor = getSystemBackgroundColor() - const [shouldAnimate, setShouldAnimate] = useState(true) - return ( - <SharedElementAnimationContextProvider value={shouldAnimate}> - <Stack - screenOptions={{ - contentStyle: { backgroundColor: systemBackgroundColor }, - headerShown: false, - }} - screenListeners={{ - transitionEnd: (e) => { - // disable shared element animation when navigating back to the start screen - const screenToStart = "index" - if (e.target?.startsWith(screenToStart)) { - setShouldAnimate(!e.data.closing) - } - }, - }} - /> - </SharedElementAnimationContextProvider> + return ( + <Stack + screenOptions={{ + contentStyle: { backgroundColor: systemBackgroundColor }, + headerShown: false, + }} + /> ) } diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx index 1ab4a6a3d4..a7995828f4 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx @@ -7,10 +7,9 @@ import PagerView from "react-native-pager-view" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" import HtmlWeb from "@/src/components/ui/typography/HtmlWeb" -import { useShouldAnimate } from "@/src/modules/entry/ctx" import { useEntry } from "@/src/store/entry/hooks" -function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) { +function Media({ media }: { media: any }) { const isVideo = media.type === "video" const imageUrl = isVideo ? media.preview_image_url : media.url const videoUrl = media.url @@ -44,7 +43,7 @@ function Media({ media, shouldAnimate }: { media: any; shouldAnimate: boolean }) aspectRatio: media?.height && media.width ? media.width / media.height : 9 / 16, display: isVideo ? (status?.isLoaded ? "none" : "flex") : "flex", }} - sharedTransitionTag={shouldAnimate && imageUrl ? `entry-image-${imageUrl}` : undefined} + sharedTransitionTag={imageUrl ? `entry-image-${imageUrl}` : undefined} allowDownscaling={false} /> ) : ( @@ -75,8 +74,6 @@ export default function EntryDetailPage() { }), ) - const shouldAnimate = useShouldAnimate() - const [currentPageIndex, setCurrentPageIndex] = useState(0) const [height, setHeight] = useState(0) @@ -101,7 +98,7 @@ export default function EntryDetailPage() { }} > {mediaList.map((media) => { - return <Media key={media.url} media={media} shouldAnimate={shouldAnimate} /> + return <Media key={media.url} media={media} /> })} </PagerView> )} From 71f6b051fadafc07b97d726e8d36bd4694f85039 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 22 Jan 2025 21:48:02 +0800 Subject: [PATCH 09/11] chore: update --- .../modules/entry-list/entry-list-gird.tsx | 98 +++++++++++++++++++ .../src/modules/entry-list/entry-list.tsx | 75 ++++++++------ apps/mobile/src/modules/entry/gird.tsx | 74 -------------- .../screens/(stack)/feeds/[feedId]/index.tsx | 4 +- apps/mobile/src/services/index.ts | 2 + 5 files changed, 147 insertions(+), 106 deletions(-) create mode 100644 apps/mobile/src/modules/entry-list/entry-list-gird.tsx delete mode 100644 apps/mobile/src/modules/entry/gird.tsx diff --git a/apps/mobile/src/modules/entry-list/entry-list-gird.tsx b/apps/mobile/src/modules/entry-list/entry-list-gird.tsx new file mode 100644 index 0000000000..bbe48c7f41 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/entry-list-gird.tsx @@ -0,0 +1,98 @@ +import { FeedViewType } from "@follow/constants" +import { useTypeScriptHappyCallback } from "@follow/hooks" +import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" +import { useHeaderHeight } from "@react-navigation/elements" +import type { MasonryFlashListProps } from "@shopify/flash-list" +import { MasonryFlashList } from "@shopify/flash-list" +import { Link } from "expo-router" +import { useContext } from "react" +import { Pressable, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import { NavigationContext } from "@/src/components/common/SafeNavigationScrollView" +import { ThemedText } from "@/src/components/common/ThemedText" +import { ItemPressable } from "@/src/components/ui/pressable/item-pressable" +import { useEntry } from "@/src/store/entry/hooks" + +import { useSelectedFeed } from "../feed-drawer/atoms" + +export function EntryListContentGrid({ + entryIds, + ...rest +}: { + entryIds: string[] +} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">) { + const insets = useSafeAreaInsets() + const tabBarHeight = useBottomTabBarHeight() + const headerHeight = useHeaderHeight() + const { scrollY } = useContext(NavigationContext)! + return ( + <MasonryFlashList + data={entryIds} + renderItem={useTypeScriptHappyCallback(({ item }) => { + return <RenderEntryItem id={item} /> + }, [])} + numColumns={2} + onScroll={useTypeScriptHappyCallback( + (e) => { + scrollY.setValue(e.nativeEvent.contentOffset.y) + }, + [scrollY], + )} + scrollIndicatorInsets={{ + top: headerHeight - insets.top, + bottom: tabBarHeight - insets.bottom, + }} + estimatedItemSize={100} + contentContainerStyle={{ + paddingTop: headerHeight, + paddingBottom: tabBarHeight, + }} + {...rest} + /> + ) +} + +function RenderEntryItem({ id }: { id: string }) { + const selectedFeed = useSelectedFeed() + const view = selectedFeed.type === "view" ? selectedFeed.viewId : null + const item = useEntry(id) + if (!item) { + return null + } + const photo = item.media?.find((media) => media.type === "photo") + const video = item.media?.find((media) => media.type === "video") + const imageUrl = photo?.url || video?.preview_image_url + const aspectRatio = + view === FeedViewType.Pictures && photo?.height && photo.width + ? photo.width / photo.height + : 16 / 9 + + return ( + <ItemPressable className="m-1 overflow-hidden rounded-md"> + <Link href={`/entries/${item.id}`} asChild> + <Pressable> + {imageUrl ? ( + <ReAnimatedExpoImage + source={{ uri: imageUrl }} + style={{ + width: "100%", + aspectRatio, + }} + sharedTransitionTag={`entry-image-${imageUrl}`} + allowDownscaling={false} + recyclingKey={imageUrl} + /> + ) : ( + <View className="aspect-video w-full items-center justify-center"> + <ThemedText className="text-center">No media available</ThemedText> + </View> + )} + </Pressable> + </Link> + + <ThemedText className="p-2">{item.title}</ThemedText> + </ItemPressable> + ) +} diff --git a/apps/mobile/src/modules/entry-list/entry-list.tsx b/apps/mobile/src/modules/entry-list/entry-list.tsx index b3f479240a..913ff1fe90 100644 --- a/apps/mobile/src/modules/entry-list/entry-list.tsx +++ b/apps/mobile/src/modules/entry-list/entry-list.tsx @@ -1,11 +1,11 @@ -import type { FeedViewType } from "@follow/constants" +import { FeedViewType } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs" import { useHeaderHeight } from "@react-navigation/elements" import { useIsFocused } from "@react-navigation/native" import { FlashList } from "@shopify/flash-list" import { router } from "expo-router" -import { useCallback, useEffect, useMemo } from "react" +import { useCallback, useContext, useEffect, useMemo } from "react" import { Image, StyleSheet, Text, useAnimatedValue, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" @@ -32,6 +32,7 @@ import { useInbox } from "@/src/store/inbox/hooks" import { useList } from "@/src/store/list/hooks" import { LeftAction, RightAction } from "./action" +import { EntryListContentGrid } from "./entry-list-gird" export function EntryList() { const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() @@ -100,9 +101,9 @@ function InboxEntryList({ inboxId }: { inboxId: string }) { function EntryListScreen({ title, entryIds }: { title: string; entryIds: string[] }) { const scrollY = useAnimatedValue(0) - const insets = useSafeAreaInsets() - const tabBarHeight = useBottomTabBarHeight() - const headerHeight = useHeaderHeight() + const selectedFeed = useSelectedFeed() + const view = selectedFeed.type === "view" ? selectedFeed.viewId : null + return ( <NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}> <NavigationBlurEffectHeader @@ -121,35 +122,49 @@ function EntryListScreen({ title, entryIds }: { title: string; entryIds: string[ [], )} /> - <FlashList - onScroll={useTypeScriptHappyCallback( - (e) => { - scrollY.setValue(e.nativeEvent.contentOffset.y) - }, - [scrollY], - )} - data={entryIds} - renderItem={useTypeScriptHappyCallback( - ({ item: id }) => ( - <EntryItem key={id} entryId={id} /> - ), - [], - )} - scrollIndicatorInsets={{ - top: headerHeight - insets.top, - bottom: tabBarHeight - insets.bottom, - }} - estimatedItemSize={100} - contentContainerStyle={{ - paddingTop: headerHeight, - paddingBottom: tabBarHeight, - }} - ItemSeparatorComponent={ItemSeparator} - /> + {view === FeedViewType.Pictures || view === FeedViewType.Videos ? ( + <EntryListContentGrid entryIds={entryIds} /> + ) : ( + <EntryListContent entryIds={entryIds} /> + )} </NavigationContext.Provider> ) } +function EntryListContent({ entryIds }: { entryIds: string[] }) { + const insets = useSafeAreaInsets() + const tabBarHeight = useBottomTabBarHeight() + const headerHeight = useHeaderHeight() + const { scrollY } = useContext(NavigationContext)! + return ( + <FlashList + onScroll={useTypeScriptHappyCallback( + (e) => { + scrollY.setValue(e.nativeEvent.contentOffset.y) + }, + [scrollY], + )} + data={entryIds} + renderItem={useTypeScriptHappyCallback( + ({ item: id }) => ( + <EntryItem key={id} entryId={id} /> + ), + [], + )} + scrollIndicatorInsets={{ + top: headerHeight - insets.top, + bottom: tabBarHeight - insets.bottom, + }} + estimatedItemSize={100} + contentContainerStyle={{ + paddingTop: headerHeight, + paddingBottom: tabBarHeight, + }} + ItemSeparatorComponent={ItemSeparator} + /> + ) +} + const ItemSeparator = () => { return ( <View diff --git a/apps/mobile/src/modules/entry/gird.tsx b/apps/mobile/src/modules/entry/gird.tsx deleted file mode 100644 index e46082e219..0000000000 --- a/apps/mobile/src/modules/entry/gird.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { MasonryFlashList } from "@shopify/flash-list" -import { Link } from "expo-router" -import { Pressable, View } from "react-native" -import { SharedTransition } from "react-native-reanimated" - -import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" -import { ThemedText } from "@/src/components/common/ThemedText" -import { useEntry } from "@/src/store/entry/hooks" - -const transition = SharedTransition.duration(300) - -export function EntryColumnGrid({ - entryIds, - onEndReached, -}: { - entryIds: string[] - onEndReached?: () => void -}) { - return ( - <MasonryFlashList - data={entryIds} - numColumns={2} - contentInsetAdjustmentBehavior="automatic" - contentContainerClassName="p-1" - renderItem={({ item }) => { - return <RenderEntryItem id={item} /> - }} - onEndReached={onEndReached} - /> - ) -} -function RenderEntryItem({ id }: { id: string }) { - const item = useEntry(id) - if (!item) { - return null - } - const photo = item.media?.find((media) => media.type === "photo") - const video = item.media?.find((media) => media.type === "video") - const imageUrl = photo?.url || video?.preview_image_url - const aspectRatio = - photo?.height && photo.width - ? photo.width / photo.height - : video?.height && video.width - ? video.width / video.height - : 16 / 9 - - return ( - <View className="m-1 overflow-hidden rounded-md bg-white"> - <Link href={`/entries/${item.id}`} asChild> - <Pressable> - {imageUrl ? ( - <ReAnimatedExpoImage - source={{ uri: imageUrl }} - style={{ - width: "100%", - aspectRatio, - }} - sharedTransitionTag={`entry-image-${imageUrl}`} - sharedTransitionStyle={transition} - allowDownscaling={false} - recyclingKey={imageUrl} - /> - ) : ( - <View className="aspect-video w-full items-center justify-center"> - <ThemedText className="text-center">No media available</ThemedText> - </View> - )} - </Pressable> - </Link> - - <ThemedText className="p-2">{item.title}</ThemedText> - </View> - ) -} diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx index 74e84f84bd..f71c12ad2b 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx @@ -3,7 +3,7 @@ import { useState } from "react" import { View } from "react-native" import { BlurEffect } from "@/src/components/common/BlurEffect" -import { EntryColumnGrid } from "@/src/modules/entry/gird" +import { EntryListContentGrid } from "@/src/modules/entry-list/entry-list-gird" import { getEntry } from "@/src/store/entry/getter" import { useEntryIdsByFeedId, usePrefetchEntries } from "@/src/store/entry/hooks" import { useFeed } from "@/src/store/feed/hooks" @@ -25,7 +25,7 @@ function FeedEntryList({ feedId }: { feedId: string }) { headerTitle: feed?.title ?? "Feed", }} /> - <EntryColumnGrid + <EntryListContentGrid entryIds={entryIds} onEndReached={() => { const lastEntryId = entryIds.at(-1) diff --git a/apps/mobile/src/services/index.ts b/apps/mobile/src/services/index.ts index 13d7740a3f..272c387b90 100644 --- a/apps/mobile/src/services/index.ts +++ b/apps/mobile/src/services/index.ts @@ -1,3 +1,4 @@ +import { EntryService } from "./entry" import { FeedService } from "./feed" import { InboxService } from "./inbox" import type { Hydratable } from "./internal/base" @@ -13,6 +14,7 @@ const hydrates: Hydratable[] = [ ListService, UnreadService, UserService, + EntryService, ] export const hydrateDatabaseToStore = async () => { From bd213b8341766a2a4ac7955a247d5299d2fd31f2 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:16:55 +0800 Subject: [PATCH 10/11] update --- apps/mobile/app.config.ts | 2 +- .../src/modules/entry-list/entry-list.tsx | 7 +- apps/mobile/src/morph/hono.ts | 33 ++++++++- apps/mobile/src/morph/types.ts | 3 +- .../entries/[entryId]/index.tsx | 67 ++++++++++++------- apps/mobile/src/store/entry/hooks.ts | 6 ++ apps/mobile/src/store/entry/store.ts | 10 ++- 7 files changed, 92 insertions(+), 36 deletions(-) rename apps/mobile/src/screens/{(headless) => (stack)}/entries/[entryId]/index.tsx (70%) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b9d5fd6e2f..d4b1050701 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -28,7 +28,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ icon: iconPath, scheme: "follow", userInterfaceStyle: "automatic", - // newArchEnabled: true, + newArchEnabled: true, ios: { supportsTablet: true, bundleIdentifier: "is.follow", diff --git a/apps/mobile/src/modules/entry-list/entry-list.tsx b/apps/mobile/src/modules/entry-list/entry-list.tsx index 913ff1fe90..aa4c8f684b 100644 --- a/apps/mobile/src/modules/entry-list/entry-list.tsx +++ b/apps/mobile/src/modules/entry-list/entry-list.tsx @@ -180,12 +180,7 @@ function EntryItem({ entryId }: { entryId: string }) { const entry = useEntry(entryId) const handlePress = useCallback(() => { - router.push({ - pathname: `/feeds/[feedId]`, - params: { - feedId: entryId, - }, - }) + router.push(`/entries/${entryId}`) }, [entryId]) if (!entry) return <EntryItemSkeleton /> diff --git a/apps/mobile/src/morph/hono.ts b/apps/mobile/src/morph/hono.ts index cb7d6f89cc..7c3a183f69 100644 --- a/apps/mobile/src/morph/hono.ts +++ b/apps/mobile/src/morph/hono.ts @@ -97,7 +97,7 @@ class Morph { } } - toEntry(data?: HonoApiClient.Entry_Get): EntryModel[] { + toEntryList(data?: HonoApiClient.Entry_Post): EntryModel[] { const entries: EntryModel[] = [] for (const item of data ?? []) { entries.push({ @@ -129,6 +129,37 @@ class Morph { } return entries } + + toEntry(data?: HonoApiClient.Entry_Get): EntryModel | null { + if (!data) return null + + return { + id: data.entries.id, + title: data.entries.title, + url: data.entries.url, + content: data.entries.content, + description: data.entries.description, + guid: data.entries.guid, + author: data.entries.author, + authorUrl: data.entries.authorUrl, + authorAvatar: data.entries.authorAvatar, + insertedAt: new Date(data.entries.insertedAt), + publishedAt: new Date(data.entries.publishedAt), + media: data.entries.media ?? null, + categories: data.entries.categories ?? null, + attachments: data.entries.attachments ?? null, + extra: data.entries.extra + ? { + links: data.entries.extra.links ?? undefined, + } + : null, + language: data.entries.language, + feedId: data.feeds.id, + // TODO: handle inboxHandle + inboxHandle: "", + read: false, + } + } } export const honoMorph = new Morph() diff --git a/apps/mobile/src/morph/types.ts b/apps/mobile/src/morph/types.ts index 375f38d5ae..1112477cc3 100644 --- a/apps/mobile/src/morph/types.ts +++ b/apps/mobile/src/morph/types.ts @@ -8,5 +8,6 @@ type ExtractData<T extends (...args: any) => any> = export namespace HonoApiClient { export type Subscription_Get = ExtractData<typeof apiClient.subscriptions.$get> export type List_Get = ExtractData<typeof apiClient.lists.$get> - export type Entry_Get = ExtractData<typeof apiClient.entries.$post> + export type Entry_Post = ExtractData<typeof apiClient.entries.$post> + export type Entry_Get = ExtractData<typeof apiClient.entries.$get> } diff --git a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx similarity index 70% rename from apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx rename to apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx index a7995828f4..40b49cc22a 100644 --- a/apps/mobile/src/screens/(headless)/entries/[entryId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/entries/[entryId]/index.tsx @@ -6,8 +6,9 @@ import { Dimensions, ScrollView, Text, View } from "react-native" import PagerView from "react-native-pager-view" import { ReAnimatedExpoImage } from "@/src/components/common/AnimatedComponents" +import { BlurEffect } from "@/src/components/common/BlurEffect" import HtmlWeb from "@/src/components/ui/typography/HtmlWeb" -import { useEntry } from "@/src/store/entry/hooks" +import { useEntry, usePrefetchEntryContent } from "@/src/store/entry/hooks" function Media({ media }: { media: any }) { const isVideo = media.type === "video" @@ -56,6 +57,7 @@ function Media({ media }: { media: any }) { export default function EntryDetailPage() { const { entryId } = useLocalSearchParams() + usePrefetchEntryContent(entryId as string) const item = useEntry(entryId as string) const mediaList = @@ -79,15 +81,27 @@ export default function EntryDetailPage() { return ( <> - <Stack.Screen options={{ animation: "fade", animationDuration: 300 }} /> - <ScrollView className="pt-safe" contentContainerClassName="flex-grow"> - <View - style={{ - height: maxMediaHeight > 0 ? maxMediaHeight : "80%", - maxHeight: "80%", - }} - > - {mediaList.length > 0 && ( + <Stack.Screen + options={{ + headerShown: true, + headerBackTitle: "Feeds", + headerBackground: BlurEffect, + headerTransparent: true, + headerTitle: item?.title ?? "Entry", + }} + /> + <ScrollView + className="pt-safe" + contentContainerClassName="flex-grow" + contentInsetAdjustmentBehavior="automatic" + > + {mediaList.length > 0 && ( + <View + style={{ + height: maxMediaHeight > 0 ? maxMediaHeight : "80%", + maxHeight: "80%", + }} + > <PagerView key={item?.id} style={{ flex: 1 }} @@ -101,22 +115,23 @@ export default function EntryDetailPage() { return <Media key={media.url} media={media} /> })} </PagerView> - )} - {mediaList.length > 1 && ( - <View className="my-2 w-full flex-row items-center justify-center gap-2"> - {Array.from({ length: mediaList.length }).map((_, index) => { - return ( - <View - key={index} - className={`size-2 rounded-full ${ - index === currentPageIndex ? "bg-red" : "bg-gray-2" - }`} - /> - ) - })} - </View> - )} - </View> + + {mediaList.length > 1 && ( + <View className="my-2 w-full flex-row items-center justify-center gap-2"> + {Array.from({ length: mediaList.length }).map((_, index) => { + return ( + <View + key={index} + className={`size-2 rounded-full ${ + index === currentPageIndex ? "bg-red" : "bg-gray-2" + }`} + /> + ) + })} + </View> + )} + </View> + )} <HtmlWeb content={item?.content || ""} onLayout={async (size) => { diff --git a/apps/mobile/src/store/entry/hooks.ts b/apps/mobile/src/store/entry/hooks.ts index c324f79a05..bcd39271de 100644 --- a/apps/mobile/src/store/entry/hooks.ts +++ b/apps/mobile/src/store/entry/hooks.ts @@ -13,6 +13,12 @@ export const usePrefetchEntries = (props: FetchEntriesProps) => { queryFn: () => entrySyncServices.fetchEntries(props), }) } +export const usePrefetchEntryContent = (entryId: string) => { + return useQuery({ + queryKey: ["entry", entryId], + queryFn: () => entrySyncServices.fetchEntryContent(entryId), + }) +} export const useEntry = (id: string): EntryModel | undefined => { return useEntryStore((state) => state.data[id]) diff --git a/apps/mobile/src/store/entry/store.ts b/apps/mobile/src/store/entry/store.ts index 0def9845aa..a6199b561c 100644 --- a/apps/mobile/src/store/entry/store.ts +++ b/apps/mobile/src/store/entry/store.ts @@ -165,7 +165,7 @@ class EntrySyncServices { }, }) - const entries = honoMorph.toEntry(res.data) + const entries = honoMorph.toEntryList(res.data) if (!pageParam) { entryActions.reset(entries) } @@ -179,6 +179,14 @@ class EntrySyncServices { } return entries } + + async fetchEntryContent(entryId: EntryId) { + const res = await apiClient.entries.$get({ query: { id: entryId } }) + const entry = honoMorph.toEntry(res.data) + if (!entry) return null + await entryActions.upsertMany([entry]) + return entry + } } export const entrySyncServices = new EntrySyncServices() From eb0a1afbbbbc94588af1100e0f57e3a69437a423 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:20:01 +0800 Subject: [PATCH 11/11] update --- .../screens/(stack)/feeds/[feedId]/index.tsx | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx index f71c12ad2b..81704ad8e5 100644 --- a/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx +++ b/apps/mobile/src/screens/(stack)/feeds/[feedId]/index.tsx @@ -1,49 +1,37 @@ import { Stack, useLocalSearchParams } from "expo-router" -import { useState } from "react" -import { View } from "react-native" +import { ScrollView, Text, View } from "react-native" import { BlurEffect } from "@/src/components/common/BlurEffect" -import { EntryListContentGrid } from "@/src/modules/entry-list/entry-list-gird" -import { getEntry } from "@/src/store/entry/getter" -import { useEntryIdsByFeedId, usePrefetchEntries } from "@/src/store/entry/hooks" -import { useFeed } from "@/src/store/feed/hooks" -function FeedEntryList({ feedId }: { feedId: string }) { - const [pageParam, setPageParam] = useState<string | undefined>() - usePrefetchEntries({ feedId, pageParam }) - const feed = useFeed(feedId) - const entryIds = useEntryIdsByFeedId(feedId) +export default function Feed() { + const { feedId } = useLocalSearchParams() return ( - <View className="flex-1 flex-row bg-gray-50"> + <View> <Stack.Screen options={{ headerShown: true, headerBackTitle: "Subscriptions", headerBackground: BlurEffect, + headerTransparent: true, - headerTitle: feed?.title ?? "Feed", - }} - /> - <EntryListContentGrid - entryIds={entryIds} - onEndReached={() => { - const lastEntryId = entryIds.at(-1) - if (!lastEntryId) return - const lastEntry = getEntry(lastEntryId) - if (!lastEntry) return - setPageParam(lastEntry.publishedAt.toISOString()) + headerTitle: "Feed", }} /> + <ScrollView contentInsetAdjustmentBehavior="automatic" className="h-full"> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + <Text>Feed {feedId}</Text> + </ScrollView> </View> ) } - -export default function Feed() { - const { feedId } = useLocalSearchParams() - if (!feedId || Array.isArray(feedId)) { - return null - } - - return <FeedEntryList feedId={feedId} /> -}