Merge branch 'main' into dev

This commit is contained in:
DIYgod 2025-06-29 10:27:30 +08:00
commit df80682b2c
No known key found for this signature in database
62 changed files with 643 additions and 470 deletions

View File

@ -0,0 +1,33 @@
# What's New in v0.6.0
## Shiny New Things
- Import and export your Actions (394d00f)
- Add a bio, website, and social links to your profile (507a525)
- Upload a profile picture
- Use video duration as an Action condition
## Improvements
- A snazzy new look for your personal profile
- Redesigned the Actions page (1ace5ea)
- Redesigned the RSSHub page (f9aca60)
- Added length limits to certain profile fields
- Simplified default commands in the entry tool (85122fb)
- Enhanced UI labels and descriptions for clarity (2ed9f70)
- Gradually rolling out an experimental unified local database for mobile and desktop (#3897 #3902)
- Polished image-preview styling (cf72753)
- Refined toast notifications (73f8011)
## No Longer Broken
- More reliable automatic recovery after database-migration failures (c2e0c3d)
- Fixed unread counts not clearing in the macOS Docker build (70255af)
- Fixed old entries showing during initial load (24ae065)
- Fixed handling of links starting with `.` (de8eac8)
- Fixed text-to-speech not working (82952b0)
- Fixed star/unstar status not syncing across devices (fbd0b3)
## Thanks
Special thanks to volunteer contributors @kovsu @huanfe1 @cscnk52 @Olexandr88 @0-o0 @kingsword09 @ericyzhu for their valuable contributions

View File

@ -1,29 +1,11 @@
# What's new in vNEXT_VERSION
## ⚠️ Important
Weve made some updates to our `user` database to help keep things running smoothly and securely. Some fields—like your name, email, profile image link, handle, bio, and website—now have maximum character limits:
- Name: up to 64 characters
- Email: up to 64 characters
- Profile image link: up to 256 characters
- Handle: up to 36 characters
- Bio: up to 256 characters
- Website: up to 256 characters
If you previously entered information thats longer than these limits, it will be automatically shortened to fit. Other fields, like your email verification status, two-factor authentication, and social links, are not affected by these changes.
## Shiny new things
- 🎉 Say hello to a snazzy new look for your personal profile! We've sprinkled in some cool social attribute settings to spice things up. But hold onto your hats—this is just the appetizer for our grand social vision! 🚀 Stay tuned for more! 😎
- We have redesigned the pages for Discover, RSSHub, and Actions. These pages are now simpler and easier to use, with a more modern UI.
## Improvements
## No longer broken
🎉 Ta-da! Weve squashed a bunch of pesky bugs you awesome folks in the community pointed out—high fives all around! 🙌 But if somethings still acting wonky, dont be shy—holler at us with an issue report, pretty please! 😜 Lets keep the good vibes rolling! 🚀
## Thanks
Special thanks to volunteer contributors @ for their valuable contributions

View File

@ -5,5 +5,6 @@ declare global {
electron?: ElectronAPI
api?: { canWindowBlur: boolean }
platform: NodeJS.Platform
mas: boolean
}
}

View File

@ -39,6 +39,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI)
contextBridge.exposeInMainWorld("api", api)
contextBridge.exposeInMainWorld("platform", process.platform)
contextBridge.exposeInMainWorld("mas", process.mas)
} catch (error) {
console.error(error)
}
@ -49,6 +50,8 @@ if (process.contextIsolated) {
window.api = api
// @ts-ignore (define in dts)
window.platform = process.platform
// @ts-ignore (define in dts)
window.mas = process.mas
Object.defineProperty(window.navigator, "clipboard", {
get: () => {

View File

@ -5,6 +5,7 @@ declare global {
electron?: ElectronAPI
api?: { canWindowBlur: boolean }
platform: NodeJS.Platform
mas: boolean
}
export const APP_NAME = "Folo"
}

View File

@ -10,9 +10,5 @@ export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = crea
export const useIsInMASReview = () => {
const serverConfigs = useServerConfigs()
return (
typeof process !== "undefined" &&
process.mas &&
serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
)
return window.mas && serverConfigs?.MAS_IN_REVIEW_VERSION === PKG.version
}

View File

@ -57,12 +57,12 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }
toast.dismiss(toastId)
}
const toastId = toast("", {
const toastId = toast.warning("", {
duration: 3000,
description: <UnfollowInfo title={feed.title!} undo={undo} />,
action: {
label: (
<span className="flex items-center gap-1">
<span className={"flex items-center gap-1 px-1"}>
{t("words.undo")}
<Kbd className="border-border inline-flex items-center border bg-transparent text-white">
$mod+Z
@ -95,13 +95,15 @@ const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => {
preventDefault: true,
})
return (
<Trans
ns="app"
i18nKey="notify.unfollow_feed"
components={{
FeedItem: <i className="mr-px font-semibold">{title}</i>,
}}
/>
<span className="text-text font-medium">
<Trans
ns="app"
i18nKey="notify.unfollow_feed"
components={{
FeedItem: <i className="mr-px font-semibold">{title}</i>,
}}
/>
</span>
)
}

View File

@ -1,10 +1,9 @@
import { env } from "@follow/shared/env.desktop"
import type { AuthSession } from "@follow/shared/hono"
import { setOpenPanelTracker, setPostHogTracker, tracker } from "@follow/tracker"
import posthog from "posthog-js"
import { setFirebaseTracker, setOpenPanelTracker, tracker } from "@follow/tracker"
import { QUERY_PERSIST_KEY } from "~/constants/app"
import { ga4 } from "./ga4"
import { op } from "./op"
export const initAnalytics = async () => {
@ -15,14 +14,9 @@ export const initAnalytics = async () => {
language: navigator.language,
})
setFirebaseTracker(ga4)
setOpenPanelTracker(op)
setPostHogTracker(
posthog.init(env.VITE_POSTHOG_KEY, {
api_host: env.VITE_POSTHOG_HOST,
person_profiles: "always",
defaults: "2025-05-24",
}),
)
let session: AuthSession | undefined
try {

View File

@ -0,0 +1,56 @@
import { v4 as uuidv4 } from "uuid"
import { apiClient } from "~/lib/api-fetch"
class Analytics4 {
private clientID: string
private sessionID: string
private userID: string | null = null
private userProperties: Record<string, { value: unknown }> | null = null
constructor(clientID: string = uuidv4(), sessionID = uuidv4()) {
this.clientID = clientID
this.sessionID = sessionID
}
async setUserId(id: string) {
this.userID = id
}
async setUserProperties(upValue?: Record<string, unknown>) {
const userProperties = Object.entries(upValue || {}).reduce((acc, [key, value]) => {
acc[key] = {
value,
}
return acc
}, {})
this.userProperties = userProperties
}
async logEvent(eventName: string, params?: Record<string, unknown>): Promise<any> {
delete params?.__code
delete params?.__eventName
const payload = {
client_id: this.clientID,
user_id: this.userID,
events: [
{
name: eventName,
params: {
session_id: this.sessionID,
engagement_time_msec: 1000,
...params,
},
},
],
user_properties: this.userProperties,
}
return apiClient.data.g.$post({
json: payload,
})
}
}
export const ga4 = new Analytics4()

View File

@ -61,6 +61,7 @@ export const apiFetch = ofetch.create({
{
closeButton: true,
duration: 10e4,
classNames: {
content: tw`w-full`,
},

View File

@ -3,7 +3,11 @@ import "@follow/components/tailwind"
import "./styles/main.css"
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context"
import {
apiClientSimpleContext,
authClientSimpleContext,
queryClientSimpleContext,
} from "@follow/store/context"
import { getOS } from "@follow/utils/utils"
import * as React from "react"
import ReactDOM from "react-dom/client"
@ -16,10 +20,12 @@ import { setAppIsReady } from "./atoms/app"
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants"
import { initializeApp } from "./initialize"
import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts"
import { queryClient } from "./lib/query-client"
import { router } from "./router"
apiClientSimpleContext.provide(apiClient)
authClientSimpleContext.provide(authClient)
queryClientSimpleContext.provide(queryClient)
initializeApp().finally(() => {
import("./push-notification").then(({ registerWebPushNotifications }) => {

View File

@ -1,3 +1,4 @@
import { toastStyles } from "@follow/components/ui/toast/styles.js"
import { stopPropagation } from "@follow/utils/dom"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
@ -13,10 +14,8 @@ export const NeedActivationToast = (props: { dimiss: () => void }) => {
<div>{t("activation.description")}</div>
<button
className="bg-accent shrink-0 text-white"
className={toastStyles.actionButton}
type="button"
data-button="true"
data-action="true"
onPointerDown={stopPropagation}
onClick={useCallback(() => {
presentActivationModal()

View File

@ -15,6 +15,7 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import { FeedViewType } from "@follow/constants"
import type { EntryModelSimple, FeedAnalyticsModel, FeedModel } from "@follow/models/types"
import { invalidateEntriesQuery } from "@follow/store/entry/hooks"
import { useFeedByIdOrUrl } from "@follow/store/feed/hooks"
import { useCategories, useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
import { subscriptionSyncService } from "@follow/store/subscription/store"
@ -36,7 +37,6 @@ import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { toastFetchError } from "~/lib/error-parser"
import { entries as entriesQuery } from "~/queries/entries"
import { feed as feedQuery, useFeedQuery } from "~/queries/feed"
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"
@ -250,13 +250,7 @@ const FeedInnerForm = ({
},
onSuccess: (data, variables) => {
if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) {
entriesQuery
.entries({
feedId: "all",
view: Number(variables.view),
excludePrivate: true,
})
.invalidate({ exact: true })
invalidateEntriesQuery({ views: [Number(variables.view)] })
}
if ("unread" in data) {

View File

@ -13,6 +13,7 @@ import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { Switch } from "@follow/components/ui/switch/index.jsx"
import { FeedViewType } from "@follow/constants"
import type { ListAnalyticsModel } from "@follow/models/types"
import { invalidateEntriesQuery } from "@follow/store/entry/hooks"
import { useListById, usePrefetchListById } from "@follow/store/list/hooks"
import type { ListModel } from "@follow/store/list/types"
import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
@ -34,7 +35,6 @@ import { useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { getFetchErrorMessage, toastFetchError } from "~/lib/error-parser"
import { getNewIssueUrl } from "~/lib/issues"
import { entries as entriesQuery } from "~/queries/entries"
import { useTOTPModalWrapper } from "../profile/hooks"
import { ViewSelectorRadioGroup } from "../shared/ViewSelectorRadioGroup"
@ -224,13 +224,7 @@ const ListInnerForm = ({
},
onSuccess: (data, variables) => {
if (getGeneralSettings().hidePrivateSubscriptionsInTimeline) {
entriesQuery
.entries({
feedId: "all",
view: Number(variables.view),
excludePrivate: true,
})
.invalidate({ exact: true })
invalidateEntriesQuery({ views: [Number(variables.view)] })
}
if ("unread" in data) {

View File

@ -32,6 +32,7 @@ import { useEventCallback } from "usehooks-ts"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContainerWidthProvider"
import type { StoreImageType } from "~/store/image"
import { imageActions } from "~/store/image"
import { getMasonryColumnValue, setMasonryColumnValue, useMasonryColumnValue } from "../atoms"
@ -54,6 +55,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
const deferIsInitLayout = useDeferredValue(isInitLayout)
const restoreDimensions = useEventCallback(async () => {
const images = [] as string[]
data.forEach((entryId) => {
const entry = getEntry(entryId)
if (!entry) return
@ -68,7 +70,30 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
setIsInitDim(true)
})
})
}, [])
}, [restoreDimensions])
useLayoutEffect(() => {
const images: StoreImageType[] = []
data.forEach((entryId) => {
const entry = getEntry(entryId)
if (!entry) return
if (!entry.media) return
for (const media of entry.media) {
if (!media.height || !media.width) continue
images.push({
src: media.url,
width: media.width,
height: media.height,
ratio: media.width / media.height,
})
}
})
if (images.length > 0) {
imageActions.saveImages(images)
}
}, [JSON.stringify(data)])
const customizeColumn = useMasonryColumnValue()
const { containerRef, currentColumn, currentItemWidth, calcItemWidth } = useMasonryColumn(

View File

@ -1,6 +1,7 @@
import { views } from "@follow/constants"
import { useCollectionEntryList } from "@follow/store/collection/hooks"
import {
useEntriesQuery,
useEntryIdsByFeedId,
useEntryIdsByFeedIds,
useEntryIdsByInboxId,
@ -21,7 +22,7 @@ import { useGeneralSettingKey } from "~/atoms/settings/general"
import { ROUTE_FEED_PENDING } from "~/constants/app"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { entries, useEntries } from "~/queries/entries"
import { entries } from "~/queries/entries"
import { useIsPreviewFeed } from "./useIsPreviewFeed"
@ -45,8 +46,10 @@ const useRemoteEntries = (): UseEntriesReturn => {
inboxId,
listId,
view,
...(unreadOnly === true && !isPreview && { read: false }),
...(hidePrivateSubscriptionsInTimeline === true && { excludePrivate: true }),
...(unreadOnly === true && !isPreview && { unreadOnly: true }),
...(hidePrivateSubscriptionsInTimeline === true && {
hidePrivateSubscriptionsInTimeline: true,
}),
}
if (feedId && listId && isBizId(feedId)) {
@ -64,7 +67,7 @@ const useRemoteEntries = (): UseEntriesReturn => {
view,
hidePrivateSubscriptionsInTimeline,
])
const query = useEntries(entriesOptions)
const query = useEntriesQuery(entriesOptions)
const [fetchedTime, setFetchedTime] = useState<number>()
useEffect(() => {
@ -155,15 +158,16 @@ const useLocalEntries = (): UseEntriesReturn => {
const allEntries = useEntryStore(
useCallback(
(state) => {
const ids = showEntriesByView
? (entryIdsByView ?? [])
: (getEntryIdsFromMultiplePlace(
entryIdsByCollections,
entryIdsByFeedId,
entryIdsByCategory,
entryIdsByListId,
entryIdsByInboxId,
) ?? [])
const ids = isCollection
? entryIdsByCollections
: showEntriesByView
? (entryIdsByView ?? [])
: (getEntryIdsFromMultiplePlace(
entryIdsByFeedId,
entryIdsByCategory,
entryIdsByListId,
entryIdsByInboxId,
) ?? [])
return ids
.map((id) => {

View File

@ -103,12 +103,13 @@ export const GridItemFooter = ({
<div className="flex items-center gap-1 truncate text-[13px]">
<FeedIcon
fallback
className="mr-0.5 flex"
noMargin
className="flex"
feed={feeds!}
entry={entry?.iconEntry}
size={18}
/>
<span className={cn("min-w-0 truncate", descriptionClassName)}>
<span className={cn("min-w-0 truncate pl-1", descriptionClassName)}>
<FeedTitle feed={feeds} />
</span>
<span className={cn("text-zinc-500", timeClassName)}>·</span>

View File

@ -35,7 +35,6 @@ import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { createErrorToaster } from "~/lib/error-parser"
import { invalidateEntriesQuery } from "~/queries/entries"
import { getPreferredTitle } from "~/store/feed/hooks"
import { useModalStack } from "../../components/ui/modal/stacked/hooks"
@ -162,12 +161,6 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
newView: nextView,
})
},
onSuccess(_data, variables) {
invalidateEntriesQuery({
views: [view, variables],
})
},
})
const [isCategoryEditing, setIsCategoryEditing] = useState(false)

View File

@ -58,7 +58,7 @@ const AppNotificationContainer: FC = () => {
const toaster = () => {
toast.success("", {
description: (
<div>
<div className="text-text font-medium">
App is upgraded to{" "}
<a
href={`${repository.url}/releases/tag/v${APP_VERSION}`}

View File

@ -12,6 +12,27 @@ import { HotkeyScope } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { COMMAND_ID } from "~/modules/command/commands/id"
const checkIsFocusInIframe = (): boolean => {
const { activeElement } = document
if (!activeElement) return false
// Check if active element is an iframe or webview
if (activeElement.tagName === "IFRAME" || activeElement.tagName === "WEBVIEW") {
return true
}
// Check if active element is inside an iframe or webview
let parent = activeElement.parentElement
while (parent) {
if (parent.tagName === "IFRAME" || parent.tagName === "WEBVIEW") {
return true
}
parent = parent.parentElement
}
return false
}
const selector = (s: EnhanceSet<string>) => s.size === 0
export const FocusableGuardProvider = () => {
const hasNoFocusable = useGlobalFocusableScopeSelector(selector)
@ -21,6 +42,10 @@ export const FocusableGuardProvider = () => {
useEffect(() => {
const timer: NodeJS.Timeout = setTimeout(() => {
if (checkIsFocusInIframe()) {
return
}
if (hasNoFocusable) {
if (hasModalRef.current) {
setGlobalFocusableScope(HotkeyScope.Modal, "append")

View File

@ -1,5 +1,5 @@
import { env } from "@follow/shared/env.desktop"
import { initializeApp } from "firebase/app"
import { getApp } from "firebase/app"
import { getMessaging, getToken } from "firebase/messaging"
import { setAppMessagingToken } from "./atoms/app"
@ -31,7 +31,7 @@ export async function registerWebPushNotifications() {
await navigator.serviceWorker.ready
const app = initializeApp(firebaseConfig)
const app = getApp()
const messaging = getMessaging(app)
const permission = await Notification.requestPermission()

View File

@ -1,61 +1,9 @@
import type { FeedViewType } from "@follow/constants"
import { useFeedUnreadIsDirty } from "@follow/store/atoms/feed"
import { entrySyncServices } from "@follow/store/entry/store"
import { useAuthInfiniteQuery, useAuthQuery } from "~/hooks/common"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { queryClient } from "~/lib/query-client"
import { getEntriesParams } from "~/lib/utils"
export function invalidateEntriesQuery({ views }: { views: FeedViewType[] }) {
return queryClient.invalidateQueries({
predicate: (query) => {
const { queryKey } = query
if (Array.isArray(queryKey) && queryKey[0] === "entries") {
const view = queryKey[2]
return views.includes(view as FeedViewType)
}
return false
},
})
}
export const entries = {
entries: ({
feedId,
inboxId,
listId,
view,
read,
excludePrivate,
limit,
}: {
feedId?: string
inboxId?: string
listId?: string
view?: number
read?: boolean
excludePrivate?: boolean
limit?: number
}) =>
defineQuery(
["entries", inboxId || listId || feedId, view, read, excludePrivate, limit],
async ({ pageParam }) =>
entrySyncServices.fetchEntries({
feedId,
inboxId,
listId,
view,
read,
excludePrivate,
limit,
pageParam: pageParam as string,
}),
{
rootKey: ["entries", inboxId || listId || feedId],
},
),
preview: (id: string) =>
defineQuery(
["entries-preview", id],
@ -123,45 +71,6 @@ export const entries = {
),
}
const defaultStaleTime = 10 * (60 * 1000) // 10 minutes
export const useEntries = ({
feedId,
inboxId,
listId,
view,
read,
excludePrivate,
}: {
feedId?: string
inboxId?: string
listId?: string
view?: number
read?: boolean
excludePrivate?: boolean
}) => {
const fetchUnread = read === false
const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || "")
return useAuthInfiniteQuery(
entries.entries({ feedId, inboxId, listId, view, read, excludePrivate }),
{
enabled: feedId !== undefined || inboxId !== undefined || listId !== undefined,
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
// DON'T refetch when the router is pop to previous page
refetchOnMount: fetchUnread && feedUnreadDirty && !history.isPop ? "always" : false,
staleTime:
// Force refetch unread entries when feed is dirty
// HACK: disable refetch when the router is pop to previous page
history.isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime,
},
)
}
export const useEntriesPreview = ({ id }: { id?: string }) =>
useAuthQuery(entries.preview(id!), {
enabled: !!id,

View File

@ -12,8 +12,6 @@ import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { toastFetchError } from "~/lib/error-parser"
import { entries } from "./entries"
type FeedQueryParams = { id?: string; url?: string }
export const feed = {
@ -76,15 +74,6 @@ export const useRefreshFeedMutation = (feedId?: string) =>
useMutation({
mutationKey: ["refreshFeed", feedId],
mutationFn: () => apiClient.feeds.refresh.$get({ query: { id: feedId! } }),
onSuccess() {
if (!feedId) return
entries
.entries({
feedId: feedId!,
})
.invalidateRoot()
},
async onError(err) {
toastFetchError(err)
},
@ -99,8 +88,7 @@ export const useResetFeed = () => {
toastIDRef.current = toast.loading(t("sidebar.feed_actions.resetting_feed"))
await apiClient.feeds.reset.$get({ query: { id: feedId } })
},
onSuccess: (_, feedId) => {
entries.entries({ feedId }).invalidateRoot()
onSuccess: () => {
toast.success(
t("sidebar.feed_actions.reset_feed_success"),
toastIDRef.current ? { id: toastIDRef.current } : undefined,

View File

@ -1,7 +1,7 @@
{
"name": "Folo",
"type": "module",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",
@ -94,5 +94,5 @@
"vite-tsconfig-paths": "5.1.4"
},
"productName": "Folo",
"mainHash": "c4275ef4fd948d72b930953bd4d4b3a45bb37a20eeab354f78b86b6eca1a19d6"
"mainHash": "a57052b632dc4602db20bd054171a736fca97cdd302764b413ed95838c7067eb"
}

View File

@ -1,5 +1,5 @@
import { isTaggedFunctionCallOf } from "ast-kit"
import type { Transformer } from "unplugin-ast/dist/types-DGZH3jc3.js"
import type { Transformer } from "unplugin-ast"
import { RemoveWrapperFunction } from "unplugin-ast/transformers"
import AST from "unplugin-ast/vite"

View File

@ -1,6 +1,10 @@
import "./global.css"
import { apiClientSimpleContext, authClientSimpleContext } from "@follow/store/context"
import {
apiClientSimpleContext,
authClientSimpleContext,
queryClientSimpleContext,
} from "@follow/store/context"
import { registerRootComponent } from "expo"
import { Image } from "expo-image"
import { LinearGradient } from "expo-linear-gradient"
@ -20,6 +24,7 @@ import { TabBarPortal } from "./lib/navigation/bottom-tab/TabBarPortal"
import { TabRoot } from "./lib/navigation/bottom-tab/TabRoot"
import { TabScreen } from "./lib/navigation/bottom-tab/TabScreen"
import { RootStackNavigation } from "./lib/navigation/StackNavigation"
import { queryClient } from "./lib/query-client"
import { RootProviders } from "./providers"
import { IndexTabScreen } from "./screens/(stack)/(tabs)"
import { DiscoverTabScreen } from "./screens/(stack)/(tabs)/discover"
@ -33,6 +38,7 @@ global.APP_NAME = "Folo"
global.ELECTRON = false
apiClientSimpleContext.provide(apiClient)
authClientSimpleContext.provide(authClient)
queryClientSimpleContext.provide(queryClient)
enableFreeze(true)
;[Image, LinearGradient].forEach((Component) => {

View File

@ -1,9 +1,5 @@
import { FeedViewType } from "@follow/constants"
import {
getInvalidateEntriesQueryPredicate,
useEntriesQuery,
useEntryIdsByFeedId,
} from "@follow/store/entry/hooks"
import { useEntriesQuery, useEntryIdsByFeedId } from "@follow/store/entry/hooks"
import { getFeedById } from "@follow/store/feed/getter"
import { getSubscriptionById } from "@follow/store/subscription/getter"
import { getSubscriptionCategory } from "@follow/store/subscription/hooks"
@ -24,7 +20,6 @@ import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformA
import { views } from "@/src/constants/views"
import { useNavigation } from "@/src/lib/navigation/hooks"
import type { Navigation } from "@/src/lib/navigation/Navigation"
import { queryClient } from "@/src/lib/query-client"
import { toast } from "@/src/lib/toast"
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]/FeedScreen"
@ -315,17 +310,11 @@ export const SubscriptionFeedCategoryContextMenu = ({
key={`SubContent/${view.name}`}
value={isSelected}
onSelect={() => {
subscriptionSyncService
.changeCategoryView({
category,
currentView,
newView: view.view,
})
.then(() => {
queryClient.invalidateQueries({
predicate: getInvalidateEntriesQueryPredicate([view.view, currentView]),
})
})
subscriptionSyncService.changeCategoryView({
category,
currentView,
newView: view.view,
})
}}
>
<ContextMenu.ItemTitle>{t(view.name, { ns: "common" })}</ContextMenu.ItemTitle>

View File

@ -234,15 +234,16 @@ function useLocalEntries(props?: UseEntriesProps): UseEntriesReturn {
const allEntries = useEntryStore(
useCallback(
(state) => {
const ids = showEntriesByView
? (entryIdsByView ?? [])
: (getEntryIdsFromMultiplePlace(
entryIdsByCollections,
entryIdsByFeedId,
entryIdsByCategory,
entryIdsByListId,
entryIdsByInboxId,
) ?? [])
const ids = isCollection
? entryIdsByCollections
: showEntriesByView
? (entryIdsByView ?? [])
: (getEntryIdsFromMultiplePlace(
entryIdsByFeedId,
entryIdsByCategory,
entryIdsByListId,
entryIdsByInboxId,
) ?? [])
return ids
.map((id) => {

View File

@ -1,10 +1,8 @@
const { createApp } = require("../dist/server/index.js")
// @ts-ignore
// eslint-disable-next-line antfu/no-import-dist
import { createApp } from "../dist/server/index.js"
// export const config = {
// runtime: "nodejs", // this is a pre-requisite
// }
module.exports = async function handler(req: any, res: any) {
export default async function handler(req: any, res: any) {
const app = await createApp()
await app.ready()
app.server.emit("request", req, res)

View File

@ -1,30 +1,82 @@
import { Header } from "@client/components/layout/header"
import { openInFollowApp } from "@client/lib/helper"
import { jotaiStore } from "@client/lib/store"
import { RootProviders } from "@client/providers/root-providers"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
import { PoweredByFooter } from "@follow/components/common/PoweredByFooter.jsx"
import { Button } from "@follow/components/ui/button/index.jsx"
import { useSyncThemeWebApp, useTitle } from "@follow/hooks"
import { Provider } from "jotai"
import { m as motion } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import { Fragment, useEffect, useState } from "react"
const NotFoundContent = () => {
const [isVisible, setIsVisible] = useState(false)
const [glitchText, setGlitchText] = useState("404")
const [isGlitching, setIsGlitching] = useState(false)
// Animation controls
const iconControls = useAnimationControls()
const messageControls = useAnimationControls()
const titleControls = useAnimationControls()
const descriptionControls = useAnimationControls()
const buttonsControls = useAnimationControls()
const helpControls = useAnimationControls()
useTitle("404 - Page Not Found")
useSyncThemeWebApp()
useEffect(() => {
setIsVisible(true)
}, [])
// Start all animations in parallel with their respective delays
iconControls.start({
scale: 1,
rotate: 0,
transition: {
duration: 0.8,
type: "spring",
stiffness: 100,
delay: 0.2,
},
})
messageControls.start({
opacity: 1,
y: 0,
transition: { duration: 0.6, delay: 0.4 },
})
titleControls.start({
opacity: 1,
x: 0,
transition: { duration: 0.5, delay: 0.6 },
})
descriptionControls.start({
opacity: 1,
x: 0,
transition: { duration: 0.5, delay: 0.8 },
})
buttonsControls.start({
opacity: 1,
scale: 1,
transition: { duration: 0.5, delay: 1 },
})
helpControls.start({
opacity: 1,
transition: { duration: 0.5, delay: 1.2 },
})
}, [
iconControls,
messageControls,
titleControls,
descriptionControls,
buttonsControls,
helpControls,
])
useEffect(() => {
if (!isGlitching) return
const glitchTexts = ["404", "40₄", "4Ø4", "", "4◯4", "4○4", "4𝟘4"]
const glitchTexts = ["404", "40₄", "4Ø4", "404", "4◯4", "4○4", "4𝟘4"]
const glitchInterval = setInterval(() => {
const randomText = glitchTexts[Math.floor(Math.random() * glitchTexts.length)]
@ -95,21 +147,15 @@ const NotFoundContent = () => {
}`}
</MemoedDangerousHTMLStyle>
<Header />
<main className="relative mx-auto flex w-full max-w-[var(--container-max-width)] flex-1 flex-col items-center justify-center pt-20">
<main className="relative mx-auto my-8 flex w-full max-w-[var(--container-max-width)] flex-1 flex-col items-center justify-center pt-20">
<Fragment>
{/* 404 Icon with animations */}
<motion.div
<m.div
className="mb-8 flex items-center justify-center"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: isVisible ? 1 : 0, rotate: isVisible ? 0 : -180 }}
transition={{
duration: 0.8,
type: "spring",
stiffness: 100,
delay: 0.2,
}}
animate={iconControls}
>
<motion.div
<m.div
className="float-animation pulse-glow-animation flex size-32 cursor-pointer items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800"
whileHover={{
scale: 1.1,
@ -131,7 +177,7 @@ const NotFoundContent = () => {
setIsGlitching(false)
}}
>
<motion.span
<m.span
className={`select-none text-4xl font-bold text-zinc-400 dark:text-zinc-600 ${isGlitching ? "glitch-animation" : ""}`}
key={glitchText}
initial={{ opacity: 0 }}
@ -139,51 +185,47 @@ const NotFoundContent = () => {
transition={{ duration: 0.1 }}
>
{glitchText}
</motion.span>
</motion.div>
</motion.div>
</m.span>
</m.div>
</m.div>
{/* Error Message with stagger animation */}
<motion.div
<m.div
className="mb-8 flex flex-col items-center text-center"
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: isVisible ? 1 : 0, y: isVisible ? 0 : 50 }}
transition={{ duration: 0.6, delay: 0.4 }}
animate={messageControls}
>
<motion.h1
<m.h1
className="mb-4 text-3xl font-bold text-zinc-900 dark:text-zinc-100"
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: isVisible ? 1 : 0, x: isVisible ? 0 : -30 }}
transition={{ duration: 0.5, delay: 0.6 }}
animate={titleControls}
>
Page Not Found
</motion.h1>
<motion.p
className="max-w-md text-lg text-zinc-500 dark:text-zinc-400"
</m.h1>
<m.p
className="max-w-md text-base text-zinc-500 dark:text-zinc-400"
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: isVisible ? 1 : 0, x: isVisible ? 0 : 30 }}
transition={{ duration: 0.5, delay: 0.8 }}
animate={descriptionControls}
>
Sorry, the page you are looking for doesn't exist or has been moved. Please check the
URL or return to the homepage to continue browsing.
</motion.p>
</motion.div>
</m.p>
</m.div>
{/* Action Buttons with hover effects */}
<motion.div
<m.div
className="flex flex-col items-center gap-4 sm:flex-row"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: isVisible ? 1 : 0, scale: isVisible ? 1 : 0.8 }}
transition={{ duration: 0.5, delay: 1 }}
animate={buttonsControls}
>
<motion.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
<m.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
<Button
onClick={handleGoHome}
buttonClassName="px-6 py-2 transition-all duration-200"
>
Go Home
</Button>
</motion.div>
</m.div>
<motion.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
<m.div whileHover={{ scale: 1.05, y: -2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="outline"
onClick={handleOpenInApp}
@ -191,18 +233,13 @@ const NotFoundContent = () => {
>
Open {APP_NAME}
</Button>
</motion.div>
</motion.div>
</m.div>
</m.div>
{/* Additional Help with fade in */}
<motion.div
className="mt-12 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: isVisible ? 1 : 0 }}
transition={{ duration: 0.5, delay: 1.2 }}
>
<m.div className="mt-12 text-center" initial={{ opacity: 0 }} animate={helpControls}>
<p className="text-sm text-zinc-400 dark:text-zinc-500">
If you believe this is an error, please submit a issue on{" "}
<motion.a
<m.a
className="text-accent transition-colors duration-200"
href="https://github.com/rssnext/folo/issues"
target="_blank"
@ -211,13 +248,13 @@ const NotFoundContent = () => {
style={{ display: "inline-block" }}
>
GitHub
</motion.a>
</m.a>
</p>
</motion.div>
</m.div>
{/* Floating particles effect */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{Array.from({ length: 6 }).map((_, i) => (
<motion.div
<m.div
key={i}
className="absolute size-1 rounded-full bg-zinc-300 opacity-30 dark:bg-zinc-600"
initial={{
@ -245,8 +282,8 @@ const NotFoundContent = () => {
export const NotFound = () => {
return (
<Provider store={jotaiStore}>
<RootProviders>
<NotFoundContent />
</Provider>
</RootProviders>
)
}

View File

@ -1,5 +1,11 @@
export const defineGlobalConstants = () => {
Object.assign(globalThis, {
APP_NAME: "Folo",
})
try {
void __DEV__
} catch {
Object.assign(globalThis, {
APP_NAME: "Folo",
__DEV__: process.env.NODE_ENV === "development",
})
}

View File

@ -1,5 +1,8 @@
import "./global"
import "./src/lib/load-env"
import os from "node:os"
import middie from "@fastify/middie"
import { fastifyRequestContext } from "@fastify/request-context"
import { env } from "@follow/shared/env.ssr"
@ -8,16 +11,12 @@ import Fastify from "fastify"
import { nanoid } from "nanoid"
import { FetchError } from "ofetch"
import { isDev } from "~/lib/env"
import { MetaError } from "~/meta-handler"
import { staticRoute } from "~/router/static"
import { defineGlobalConstants } from "./global"
import { globalRoute } from "./src/router/global"
import { ogRoute } from "./src/router/og"
defineGlobalConstants()
const isVercel = process.env.VERCEL === "1"
declare module "@fastify/request-context" {
@ -64,7 +63,7 @@ export const createApp = async () => {
const finalHost = forwardedHost || host
const upstreamEnv = finalHost?.includes("dev") ? "dev" : "prod"
if (!isDev) req.requestContext.set("upstreamEnv", upstreamEnv)
if (!__DEV__) req.requestContext.set("upstreamEnv", upstreamEnv)
if (upstreamEnv === "prod") {
req.requestContext.set("upstreamOrigin", env.VITE_WEB_PROD_URL || env.VITE_WEB_URL)
} else {
@ -74,8 +73,8 @@ export const createApp = async () => {
done()
})
if (isDev) {
const devVite = require("./src/lib/dev-vite")
if (__DEV__) {
const devVite = await import("./src/lib/dev-vite")
await devVite.registerDevViteServer(app)
}
@ -97,11 +96,11 @@ if (!isVercel) {
}
function getIPAddress() {
const interfaces = require("node:os").networkInterfaces()
const interfaces = os.networkInterfaces()
for (const devName in interfaces) {
const iface = interfaces[devName]
for (const alias of iface) {
for (const alias of iface || []) {
if (alias.family === "IPv4" && alias.address !== "127.0.0.1" && !alias.internal)
return alias.address
}

View File

@ -1,5 +1,6 @@
{
"name": "@follow/ssr",
"type": "module",
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsdown && tsx scripts/cleanup-vercel-build.ts",

View File

@ -1,10 +1,7 @@
import { rmSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"
import { rmSync } from "node:fs"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
rmSync(resolve(__dirname, "../.generated"), { recursive: true, force: true })
// restore env file
writeFileSync(
resolve(__dirname, "../src/lib/env.ts"),
`export const isDev = process.env.NODE_ENV === "development"\n`,
)

View File

@ -1,6 +1,9 @@
import { mkdirSync } from "node:fs"
import fs from "node:fs/promises"
import path from "node:path"
import path, { dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
mkdirSync(path.join(__dirname, "../.generated"), { recursive: true })
@ -12,21 +15,8 @@ async function generateIndexHtmlData() {
)
}
async function replaceEnvFile() {
const envFile = await fs.readFile(path.join(__dirname, "../src/lib/env.ts"), "utf-8")
await fs.writeFile(
path.join(__dirname, "../src/lib/env.ts"),
// For tree shaking
envFile.replace(
`export const isDev = process.env.NODE_ENV === "development"`,
`export const isDev = ${process.env.NODE_ENV === "development"}`,
),
)
}
async function main() {
await Promise.all([generateIndexHtmlData(), replaceEnvFile()])
await generateIndexHtmlData()
}
main()

View File

@ -7,7 +7,6 @@ import { hc } from "hono/client"
import { ofetch } from "ofetch"
import PKG from "../../../desktop/package.json"
import { isDev } from "./env"
const getBaseURL = () => {
const req = requestContext.get("req")!
@ -34,7 +33,7 @@ export const createApiFetch = () => {
credentials: "include",
retry: false,
onRequest(context) {
if (isDev) console.info(`request: ${context.request}`)
if (__DEV__) console.info(`request: ${context.request}`)
context.options.headers.set("User-Agent", `Folo External Server Api Client/${PKG.version}`)
},
@ -57,7 +56,7 @@ export const createApiClient = () => {
headers() {
return {
"X-App-Version": PKG.version,
"X-App-Dev": isDev ? "1" : "0",
"X-App-Dev": __DEV__ ? "1" : "0",
"User-Agent": `Folo External Server Api Client/${PKG.version}`,
Cookie: authSessionToken ? `__Secure-better-auth.session_token=${authSessionToken}` : "",
}

View File

@ -1,8 +1,10 @@
import { resolve } from "node:path"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import type { FastifyInstance } from "fastify"
import type { ViteDevServer } from "vite"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "../..")
let globalVite: ViteDevServer

View File

@ -1 +0,0 @@
export const isDev = process.env.NODE_ENV === "development"

View File

@ -1,6 +1,9 @@
import fs from "node:fs"
import { createRequire } from "node:module"
import path, { resolve } from "node:path"
const require = createRequire(import.meta.url)
const weights = [
{
name: "Thin",

View File

@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"
import path, { resolve } from "node:path"
import path, { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { env } from "@follow/shared/env.ssr"
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
@ -8,7 +9,6 @@ import { parseHTML } from "linkedom"
import { FetchError } from "ofetch"
import xss from "xss"
import { isDev } from "~/lib/env"
import { NotFoundError } from "~/lib/not-found"
import { buildSeoMetaTags } from "~/lib/seo"
@ -17,10 +17,11 @@ import { injectMetaHandler, MetaError } from "../meta-handler"
const devHandler = (app: FastifyInstance) => {
app.get("*", async (req, reply) => {
const url = req.originalUrl
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "../..")
const vite = require("../lib/dev-vite").getViteServer()
const vite = await import("../lib/dev-vite").then((m) => m.getViteServer())
try {
let template = readFileSync(path.resolve(root, vite.config.root, "index.html"), "utf-8")
template = await vite.transformIndexHtml(url, template)
@ -30,14 +31,15 @@ const devHandler = (app: FastifyInstance) => {
reply.type("text/html")
reply.send(document.toString())
} catch (e) {
vite.ssrFixStacktrace(e)
vite.ssrFixStacktrace(e as Error)
reply.code(500).send(e)
}
})
}
const prodHandler = (app: FastifyInstance) => {
app.get("*", async (req, reply) => {
const template = require("../../.generated/index.template").default
// @ts-expect-error
const template = await import("../../.generated/index.template").then((m) => m.default)
const { document } = parseHTML(template)
await safeInjectMetaToTemplate(document, req, reply)
@ -91,7 +93,7 @@ injectEnv({"VITE_API_URL":"${apiUrl}","VITE_EXTERNAL_API_URL":"${apiUrl}","VITE_
)
})
}
export const globalRoute = isDev ? devHandler : prodHandler
export const globalRoute = __DEV__ ? devHandler : prodHandler
async function safeInjectMetaToTemplate(
document: Document,

View File

@ -69,11 +69,7 @@ export const OGCanvas = ({ children, seed }: { children: React.ReactNode; seed:
{/* Follow Logo */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<FollowIcon />
<span
style={{ fontSize: 28, fontWeight: "bold", color: "white", letterSpacing: "0.05em" }}
>
Folo
</span>
<LogoText />
</div>
{/* AI RSS */}
@ -132,6 +128,35 @@ function FollowIcon() {
)
}
function LogoText() {
return (
<svg
// width="115"
height="30"
viewBox="0 0 115 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M97.4813 49.2552C87.5432 49.2552 80.678 42.0631 80.678 31.9288C80.678 21.6637 87.4778 14.4062 97.4813 14.4062C107.55 14.4062 114.35 21.6637 114.35 31.9288C114.35 42.0631 107.485 49.2552 97.4813 49.2552ZM97.4813 42.1939C102.843 42.1939 106.112 37.944 106.112 31.8634C106.112 25.7174 102.843 21.4676 97.4813 21.4676C92.1199 21.4676 88.9816 25.7174 88.9816 31.8634C88.9816 37.944 92.1199 42.1939 97.4813 42.1939Z"
fill="#fff"
/>
<path
d="M72.7289 48.6667C70.1136 48.6667 68.6752 46.9668 68.6752 44.0899V5.38342C68.6752 2.50658 70.1136 0.806641 72.7289 0.806641C75.3442 0.806641 76.7826 2.50658 76.7826 5.38342V44.0899C76.7826 46.9668 75.4096 48.6667 72.7289 48.6667Z"
fill="#fff"
/>
<path
d="M47.8314 49.2552C37.8932 49.2552 31.0281 42.0631 31.0281 31.9288C31.0281 21.6637 37.8279 14.4062 47.8314 14.4062C57.9003 14.4062 64.7001 21.6637 64.7001 31.9288C64.7001 42.0631 57.8349 49.2552 47.8314 49.2552ZM47.8314 42.1939C53.1928 42.1939 56.4619 37.944 56.4619 31.8634C56.4619 25.7174 53.1928 21.4676 47.8314 21.4676C42.47 21.4676 39.3317 25.7174 39.3317 31.8634C39.3317 37.944 42.47 42.1939 47.8314 42.1939Z"
fill="#fff"
/>
<path
d="M5.10235 48.6669C2.42166 48.6669 0.852478 46.967 0.852478 43.9594V7.21438C0.852478 4.20678 2.55243 2.50684 5.62541 2.50684H26.1555C28.967 2.50684 30.5362 3.87987 30.5362 6.23365C30.5362 8.58742 28.967 9.89507 26.1555 9.89507H9.35221V20.9447H24.7825C27.594 20.9447 29.1631 22.187 29.1631 24.5408C29.1631 26.9599 27.594 28.2022 24.7825 28.2022H9.35221V43.9594C9.35221 46.967 7.84842 48.6669 5.10235 48.6669Z"
fill="#fff"
/>
</svg>
)
}
export async function getImageBase64(image: string | null | undefined) {
if (!image) {
return null

View File

@ -9,12 +9,14 @@ export default defineConfig({
outDir: "dist/server",
clean: true,
format: ["cjs"],
format: ["esm"],
external: ["lightningcss", "vite"],
treeshake: true,
define: {
__DEV__: JSON.stringify(process.env.NODE_ENV === "development"),
},
hooks(hooks) {
hooks.hook("build:done", async () => {
if (process.env.VERCEL !== "1") return
@ -24,10 +26,11 @@ export default defineConfig({
try {
const insertCode = `try {
require.resolve("@fontsource/sn-pro")
require.resolve('kose-font')
require.resolve('kose-font/fonts/KosefontP-JP.ttf')
require.resolve('kose-font/fonts/Kosefont-JP.ttf')
const noop = () => {}
import("@fontsource/sn-pro").then(noop)
import('kose-font').then(noop)
import('kose-font/fonts/KosefontP-JP.ttf').then(noop)
import('kose-font/fonts/Kosefont-JP.ttf').then(noop)
${(() => {
const require = createRequire(import.meta.url)
const fontDepsPath = require.resolve("@fontsource/sn-pro")

View File

@ -1,4 +1,5 @@
import { resolve } from "node:path"
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import react from "@vitejs/plugin-react"
import { codeInspectorPlugin } from "code-inspector-plugin"
@ -13,6 +14,8 @@ const routeBuilderPluginV2 = await tsImport(
import.meta.url,
).then((m) => m.default)
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
resolve: {
alias: {

View File

@ -41,7 +41,7 @@
"login.confirm_password.label": "Confirm Password",
"login.continueWith": "Continue with {{provider}}",
"login.email": "Email",
"login.enter_token": "If you aren't redirected automatically, copy the token below and paste it in the \"Enter authorization token to continue\" form on the Desktop App.",
"login.enter_token": "If you aren't redirected automatically, copy the token below and paste it in the \"Enter authorization token to continue\" form on the Desktop App (v0.5.0+).",
"login.errors.unknown": "Errors Unknown",
"login.forget_password.description": "Enter the email address associated with your account and we'll send you an email about how to reset your password.",
"login.forget_password.email_invalid": "Invalid email",

View File

@ -41,7 +41,7 @@
"login.confirm_password.label": "确认密码",
"login.continueWith": "使用 {{provider}} 登录",
"login.email": "邮件地址",
"login.enter_token": "如果未自动重定向,请复制下方令牌并粘贴到桌面应用的“输入授权令牌以继续”表单中。",
"login.enter_token": "如果未自动重定向,请复制下方令牌并粘贴到桌面应用v0.5.0 以上)的“输入授权令牌以继续”表单中。",
"login.errors.unknown": "未知错误",
"login.forget_password.description": "请输入与你的帐户关联的邮件地址,我们将向你发送一封关于如何重置密码的邮件。",
"login.forget_password.email_invalid": "无效的邮箱地址",

View File

@ -1,9 +1,8 @@
import { LazyMotion, MotionConfig } from "motion/react"
import { domMax, LazyMotion, MotionConfig } from "motion/react"
const loadFeatures = () => import("../framer-lazy-feature").then((res) => res.default)
export const MotionProvider = ({ children }: { children: React.ReactNode }) => {
return (
<LazyMotion features={loadFeatures} strict key="framer">
<LazyMotion features={domMax} strict key="framer">
<MotionConfig
transition={{
type: "tween",

View File

@ -1 +0,0 @@
export { domMax as default } from "motion/react"

View File

@ -2,6 +2,7 @@ import { useIsDark } from "@follow/hooks"
import { Toaster as Sonner } from "sonner"
import { ZIndexProvider } from "../z-index"
import { toastStyles } from "./styles"
type ToasterProps = React.ComponentProps<typeof Sonner>
const TOAST_Z_INDEX = 999999999
@ -16,70 +17,7 @@ export const Toaster = ({ ...props }: ToasterProps) => {
gap={12}
toastOptions={{
unstyled: true,
classNames: {
toast: tw`
group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4 shadow-lg
backdrop-blur-xl border border-border/50
bg-material-ultra-thick
transition-all duration-300 ease-out
hover:scale-[1.02] hover:shadow-xl
data-[type=success]:border-green/30 data-[type=success]:bg-green/5
data-[type=error]:border-red/30 data-[type=error]:bg-red/5
data-[type=warning]:border-orange/30 data-[type=warning]:bg-orange/5
data-[type=info]:border-blue/30 data-[type=info]:bg-blue/5
data-[type=loading]:border-gray/30 data-[type=loading]:bg-gray/5
max-w-md min-w-[320px]
font-theme
`,
title: tw`
text-sm font-medium text-text
leading-tight
`,
description: tw`
text-xs text-text-secondary
leading-relaxed mt-1
`,
content: tw`
flex-1 min-w-0
`,
icon: tw`
flex-shrink-0 mt-0.5 size-5
[li[data-type="success"]_&]:text-green
[li[data-type="error"]_&]:text-red
[li[data-type="warning"]_&]:text-orange
[li[data-type="info"]_&]:text-blue
[li[data-type="loading"]_&]:text-gray
`,
actionButton: tw`
px-2.5 py-1 text-xs font-medium rounded-md
transition-all duration-200
focus:outline-none focus:shadow-lg bg-accent
group-data-[type=success]:bg-green group-data-[type=success]:text-white group-data-[type=success]:hover:bg-green/90 group-data-[type=success]:focus:shadow-green/50
group-data-[type=error]:bg-red group-data-[type=error]:text-white group-data-[type=error]:hover:bg-red/90 group-data-[type=error]:focus:shadow-red/50
group-data-[type=warning]:bg-orange group-data-[type=warning]:text-white group-data-[type=warning]:hover:bg-orange/90 group-data-[type=warning]:focus:shadow-orange/50
group-data-[type=info]:bg-blue group-data-[type=info]:text-white group-data-[type=info]:hover:bg-blue/90 group-data-[type=info]:focus:shadow-blue/50
group-data-[type=loading]:bg-gray group-data-[type=loading]:text-white group-data-[type=loading]:hover:bg-gray/90 group-data-[type=loading]:focus:shadow-gray/50
hover:shadow-md active:scale-95
`,
cancelButton: tw`
px-2.5 py-1 text-xs font-medium rounded-md
bg-fill-secondary text-text-secondary
hover:bg-fill-tertiary hover:text-text
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-fill/50 focus:ring-offset-1
`,
closeButton: tw`
absolute top-2 right-2 w-6 h-6 rounded-full
flex items-center justify-center
bg-fill text-text-tertiary
hover:bg-fill-secondary hover:text-text-secondary
active:bg-fill-tertiary active:text-text
transition-all duration-200
opacity-0 group-hover:opacity-100
focus:outline-none focus:ring-2 focus:ring-accent/50
focus:opacity-100
`,
},
classNames: toastStyles,
}}
icons={{
success: <i className="i-mgc-check-circle-cute-re" />,

View File

@ -0,0 +1,66 @@
export const toastStyles = {
toast: tw`
group relative flex w-full items-center justify-between gap-3 rounded-2xl p-4 shadow-lg
backdrop-blur-background border border-border/50
bg-material-ultra-thick duration-300 ease-out
data-[type=success]:border-green/30 data-[type=success]:bg-green/20
data-[type=error]:border-red/30 data-[type=error]:bg-red/20
data-[type=warning]:border-orange/30 data-[type=warning]:bg-orange/20
data-[type=info]:border-blue/30 data-[type=info]:bg-blue/20
data-[type=loading]:border-gray/30 data-[type=loading]:bg-gray/20
max-w-md min-w-[320px]
font-theme
`,
title: tw`
text-sm font-medium text-text
leading-tight
`,
description: tw`
text-xs text-text-secondary
leading-relaxed mt-1
`,
content: tw`
flex-1 min-w-0
`,
icon: tw`
flex-shrink-0 mt-0.5 size-5
[li[data-type="success"]_&]:text-green
[li[data-type="error"]_&]:text-red
[li[data-type="warning"]_&]:text-orange
[li[data-type="info"]_&]:text-blue
[li[data-type="loading"]_&]:text-gray
`,
actionButton: tw`
shrink-0
h-6
px-2.5 text-xs font-medium rounded-md
transition-all duration-200
focus:outline-none focus:shadow-lg bg-accent
group-data-[type=success]:bg-green group-data-[type=success]:text-white group-data-[type=success]:hover:bg-green/90 group-data-[type=success]:focus:shadow-green/50
group-data-[type=error]:bg-red group-data-[type=error]:text-white group-data-[type=error]:hover:bg-red/90 group-data-[type=error]:focus:shadow-red/50
group-data-[type=warning]:bg-orange group-data-[type=warning]:text-white group-data-[type=warning]:hover:bg-orange/90 group-data-[type=warning]:focus:shadow-orange/50
group-data-[type=info]:bg-blue group-data-[type=info]:text-white group-data-[type=info]:hover:bg-blue/90 group-data-[type=info]:focus:shadow-blue/50
group-data-[type=loading]:bg-gray group-data-[type=loading]:text-white group-data-[type=loading]:hover:bg-gray/90 group-data-[type=loading]:focus:shadow-gray/50
hover:shadow-md active:scale-95
`,
cancelButton: tw`
h-6
px-2.5 text-xs font-medium rounded-md
bg-fill-secondary text-text-secondary
hover:bg-fill-tertiary hover:text-text
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-fill/50 focus:ring-offset-1
`,
closeButton: tw`
absolute -top-2 -right-2 w-6 h-6 rounded-full
flex items-center justify-center
text-text
border border-border
backdrop-blur-background
bg-material-ultra-thick
transition-all duration-200
opacity-0 group-hover:opacity-100
focus:outline-none focus:ring-2 focus:ring-accent/50
focus:opacity-100
`,
}

View File

@ -35,7 +35,7 @@ const TooltipContent = ({
<m.div
initial={{ opacity: 0.82, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={Spring.presets.snappy}
transition={Spring.snappy(0.1)}
>
{/* https://github.com/radix-ui/primitives/discussions/868 */}
<TooltipPrimitive.Arrow className="z-50 fill-white [clip-path:inset(0_-10px_-10px_-10px)] dark:fill-neutral-950 dark:drop-shadow-[0_0_1px_theme(colors.white/0.5)]" />

View File

@ -1,5 +1,6 @@
{
"name": "@follow/constants",
"type": "module",
"private": true,
"sideEffects": false,
"exports": {

View File

@ -11,9 +11,13 @@ class CollectionServiceStatic implements Resetable {
await db.delete(collectionsTable).execute()
}
async upsertMany(collections: CollectionSchema[]) {
async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) {
if (collections.length === 0) return
if (options?.reset) {
await db.delete(collectionsTable).execute()
}
await db
.insert(collectionsTable)
.values(collections)
@ -27,6 +31,11 @@ class CollectionServiceStatic implements Resetable {
await db.delete(collectionsTable).where(eq(collectionsTable.entryId, entryId))
}
async deleteMany(entryId: string[]) {
if (entryId.length === 0) return
await db.delete(collectionsTable).where(inArray(collectionsTable.entryId, entryId))
}
getCollectionMany(entryId: string[]) {
return db.query.collectionsTable.findMany({ where: inArray(collectionsTable.entryId, entryId) })
}

View File

@ -20928,7 +20928,18 @@ declare const _routes: hono_hono_base.HonoBase<Env, ({
status: 200;
};
};
}, "/trending">, "/">;
}, "/trending"> | hono_types.MergeSchemaPath<{
"/g": {
$post: {
input: {
json: any;
};
output: Response;
outputFormat: "json";
status: hono_utils_http_status.StatusCode;
};
};
}, "/data">, "/">;
type AppType = typeof _routes;
export { type ActionItem, type ActionsModel, type AirdropActivity, type AppType, type AttachmentsModel, type AuthSession, type AuthUser, CommonEntryFields, type ConditionItem, type DetailModel, type EntriesModel, type ExtraModel, type FeedModel, type ListModel, type MediaModel, type MessagingData, MessagingType, type SettingsModel, type UrlReadsModel, account, achievements, achievementsOpenAPISchema, actions, actionsItemOpenAPISchema, actionsOpenAPISchema, actionsRelations, activityEnum, airdrops, airdropsOpenAPISchema, attachmentsZodSchema, authPlugins, boosts, captcha, collections, collectionsOpenAPISchema, collectionsRelations, detailModelSchema, entries, entriesOpenAPISchema, entriesRelations, extraZodSchema, feedAnalytics, feedAnalyticsOpenAPISchema, feedAnalyticsRelations, feedPowerTokens, feedPowerTokensOpenAPISchema, feedPowerTokensRelations, feeds, feedsOpenAPISchema, feedsRelations, inboxHandleSchema, inboxes, inboxesEntries, inboxesEntriesInsertOpenAPISchema, type inboxesEntriesModel, inboxesEntriesOpenAPISchema, inboxesEntriesRelations, inboxesOpenAPISchema, inboxesRelations, invitations, invitationsOpenAPISchema, invitationsRelations, languageSchema, levels, levelsOpenAPISchema, levelsRelations, listAnalytics, listAnalyticsOpenAPISchema, listAnalyticsRelations, lists, listsOpenAPISchema, listsRelations, listsSubscriptions, listsSubscriptionsOpenAPISchema, listsSubscriptionsRelations, lower, mediaZodSchema, messaging, messagingOpenAPISchema, messagingRelations, readabilities, rsshub, rsshubAnalytics, rsshubAnalyticsOpenAPISchema, rsshubOpenAPISchema, rsshubPurchase, rsshubUsage, rsshubUsageOpenAPISchema, rsshubUsageRelations, session, settings, subscriptions, subscriptionsOpenAPISchema, subscriptionsRelations, timeline, timelineOpenAPISchema, timelineRelations, transactionType, transactions, transactionsOpenAPISchema, transactionsRelations, trendingFeeds, trendingFeedsOpenAPISchema, trendingFeedsRelations, twoFactor, uploads, urlReads, urlReadsOpenAPISchema, user, users, usersOpenApiSchema, usersRelations, verification, wallets, walletsOpenAPISchema, walletsRelations };

View File

@ -4,7 +4,8 @@ import { CollectionService } from "@follow/database/services/collection"
import { apiClient } from "../context"
import { getEntry } from "../entry/getter"
import type { Hydratable } from "../internal/base"
import { invalidateEntriesQuery } from "../entry/hooks"
import type { Hydratable, Resetable } from "../internal/base"
import { createTransaction, createZustandStore } from "../internal/helper"
interface CollectionState {
@ -52,6 +53,8 @@ class CollectionSyncService {
})
await tx.run()
invalidateEntriesQuery({ collection: true })
}
async unstarEntry(entryId: string) {
@ -75,20 +78,24 @@ class CollectionSyncService {
})
await tx.run()
invalidateEntriesQuery({ collection: true })
}
}
class CollectionActions implements Hydratable {
class CollectionActions implements Hydratable, Resetable {
async hydrate() {
const collections = await CollectionService.getCollectionAll()
collectionActions.upsertManyInSession(collections)
}
upsertManyInSession(collections: CollectionSchema[]) {
upsertManyInSession(collections: CollectionSchema[], options?: { reset?: boolean }) {
const state = get()
const nextCollections: CollectionState["collections"] = {
...state.collections,
}
const nextCollections: CollectionState["collections"] = options?.reset
? {}
: {
...state.collections,
}
collections.forEach((collection) => {
if (!collection.entryId) return
nextCollections[collection.entryId] = collection
@ -99,42 +106,63 @@ class CollectionActions implements Hydratable {
})
}
async upsertMany(collections: CollectionSchema[]) {
async upsertMany(collections: CollectionSchema[], options?: { reset?: boolean }) {
const tx = createTransaction()
tx.store(() => {
this.upsertManyInSession(collections)
this.upsertManyInSession(collections, options)
})
tx.persist(() => {
return CollectionService.upsertMany(collections)
return CollectionService.upsertMany(collections, options)
})
await tx.run()
}
async deleteInSession(entryId: string) {
deleteInSession(entryId: string | string[]) {
const normalizedEntryId = Array.isArray(entryId) ? entryId : [entryId]
const state = useCollectionStore.getState()
const nextCollections: CollectionState["collections"] = {
...state.collections,
}
delete nextCollections[entryId]
normalizedEntryId.forEach((id) => {
delete nextCollections[id]
})
set({
...state,
collections: nextCollections,
})
}
async delete(entryId: string) {
async delete(entryId: string | string[]) {
const entryIdsInCollection = new Set(Object.keys(get().collections))
const normalizedEntryId = (Array.isArray(entryId) ? entryId : [entryId]).filter((id) =>
entryIdsInCollection.has(id),
)
if (normalizedEntryId.length === 0) return
const tx = createTransaction()
tx.store(() => {
this.deleteInSession(entryId)
})
tx.persist(() => {
return CollectionService.delete(entryId)
return CollectionService.deleteMany(normalizedEntryId)
})
tx.run()
}
reset() {
set(defaultState)
async reset() {
const tx = createTransaction()
tx.store(() => {
set(defaultState)
})
tx.persist(() => {
return CollectionService.reset()
})
await tx.run()
}
}

View File

@ -1,4 +1,5 @@
import type { AuthClient } from "@follow/shared/auth"
import type { QueryClient } from "@tanstack/react-query"
import type { APIClient } from "./types"
@ -27,5 +28,7 @@ function createSimpleContext<T>() {
export const apiClientSimpleContext = createSimpleContext<APIClient>()
export const authClientSimpleContext = createSimpleContext<AuthClient>()
export const queryClientSimpleContext = createSimpleContext<QueryClient>()
export const apiClient = apiClientSimpleContext.consumer
export const authClient = authClientSimpleContext.consumer
export const queryClient = queryClientSimpleContext.consumer

View File

@ -1,26 +1,48 @@
import type { FeedViewType } from "@follow/constants"
import type { Query } from "@tanstack/react-query"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { useCallback } from "react"
import { useFeedUnreadIsDirty } from "../atoms/feed"
import { FEED_COLLECTION_LIST } from "../constants/app"
import { queryClient } from "../context"
import { getSubscriptionByEntryId } from "../subscription/getter"
import { getEntry } from "./getter"
import { entrySyncServices, useEntryStore } from "./store"
import type { EntryModel, FetchEntriesProps, FetchEntriesPropsSettings } from "./types"
export const getInvalidateEntriesQueryPredicate = (views: FeedViewType[]) => {
return (query: Query) => {
const { queryKey } = query
if (Array.isArray(queryKey) && queryKey[0] === "entries") {
const view = queryKey[4]
return views.includes(view as FeedViewType)
}
return false
}
export const invalidateEntriesQuery = ({
views,
collection,
}: {
views?: FeedViewType[]
collection?: true
}) => {
return queryClient().invalidateQueries({
predicate: (query) => {
const { queryKey } = query
if (Array.isArray(queryKey) && queryKey[0] === "entries") {
const feedId = queryKey[1]
const view = queryKey[4]
const isCollection = queryKey[7]
if (views) {
return views.includes(view as FeedViewType)
}
if (collection) {
return isCollection === true || feedId === FEED_COLLECTION_LIST
}
}
return false
},
})
}
const defaultStaleTime = 10 * (60 * 1000) // 10 minutes
export const useEntriesQuery = (
props?: Omit<FetchEntriesProps, "pageParam" | "read"> & FetchEntriesPropsSettings,
props?: Omit<FetchEntriesProps, "pageParam" | "read" | "excludePrivate"> &
FetchEntriesPropsSettings,
) => {
const {
feedId,
@ -29,10 +51,17 @@ export const useEntriesQuery = (
view,
limit,
feedIdList,
isCollection,
unreadOnly,
hidePrivateSubscriptionsInTimeline,
} = props || {}
const fetchUnread = unreadOnly
const feedUnreadDirty = useFeedUnreadIsDirty((feedId as string) || "")
const isPop =
"history" in globalThis && "isPop" in globalThis.history && !!globalThis.history.isPop
return useInfiniteQuery({
queryKey: [
"entries",
@ -40,9 +69,10 @@ export const useEntriesQuery = (
inboxId,
listId,
view,
unreadOnly,
limit,
feedIdList,
isCollection,
unreadOnly,
hidePrivateSubscriptionsInTimeline,
],
queryFn: ({ pageParam }) =>
@ -52,11 +82,18 @@ export const useEntriesQuery = (
read: unreadOnly ? false : undefined,
excludePrivate: hidePrivateSubscriptionsInTimeline,
}),
staleTime: 3 * 60 * 1000,
getNextPageParam: (lastPage) => lastPage.data?.at(-1)?.entries.publishedAt,
initialPageParam: undefined as undefined | string,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
// DON'T refetch when the router is pop to previous page
refetchOnMount: fetchUnread && feedUnreadDirty && !isPop ? "always" : false,
staleTime:
// Force refetch unread entries when feed is dirty
// HACK: disable refetch when the router is pop to previous page
isPop ? Infinity : fetchUnread && feedUnreadDirty ? 0 : defaultStaleTime,
enabled: !!props,
})
}
@ -104,7 +141,7 @@ function sortEntryIdsByPublishDate(a: string, b: string) {
return entryB.publishedAt.getTime() - entryA.publishedAt.getTime()
}
export const useEntryIdsByView = (view: FeedViewType, excludePrivate: boolean) => {
export const useEntryIdsByView = (view: FeedViewType, excludePrivate: boolean | undefined) => {
return useEntryStore(
useCallback(
(state) => {

View File

@ -489,12 +489,12 @@ class EntrySyncServices {
await entryActions.upsertMany(entries)
if (isCollection && res.data) {
if (view === undefined) {
console.error("view is required for collection")
}
const collections = honoMorph.toCollections(res.data, view ?? 0)
await collectionActions.upsertMany(collections)
if (typeof view === "number") {
const { collections, entryIdsNotInCollections } = honoMorph.toCollections(res.data, view)
await collectionActions.upsertMany(collections, {
reset: params.isCollection && !pageParam,
})
await collectionActions.delete(entryIdsNotInCollections)
}
const dataFeeds = res.data?.map((e) => e.feeds).filter((f) => f.type === "feed")

View File

@ -16,8 +16,8 @@ export type FetchEntriesProps = {
}
export type FetchEntriesPropsSettings = {
hidePrivateSubscriptionsInTimeline: boolean
unreadOnly: boolean
hidePrivateSubscriptionsInTimeline?: boolean
unreadOnly?: boolean
}
export type UseEntriesProps = {

View File

@ -146,23 +146,33 @@ class Morph {
}
toCollections(
data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post,
data: HonoApiClient.Entry_Post | HonoApiClient.Entry_Inbox_Post | undefined,
view: FeedViewType,
): CollectionModel[] {
if (!data) return [] satisfies CollectionModel[]
return data
.map((item) => {
if (!item.collections) {
return null
}
return {
createdAt: item.collections.createdAt,
entryId: item.entries.id,
feedId: item.feeds.id,
view,
} satisfies CollectionModel
): {
collections: CollectionModel[]
entryIdsNotInCollections: string[]
} {
if (!data) return { collections: [], entryIdsNotInCollections: [] }
const collections: CollectionModel[] = []
const entryIdsNotInCollections: string[] = []
for (const item of data) {
if (!item.collections) {
entryIdsNotInCollections.push(item.entries.id)
continue
}
collections.push({
createdAt: item.collections.createdAt,
entryId: item.entries.id,
feedId: item.feeds.id,
view,
})
.filter((i) => i !== null)
}
return {
collections,
entryIdsNotInCollections,
}
}
toEntry(data?: HonoApiClient.Entry_Get | HonoApiClient.Entry_Inbox_Get): EntryModel | null {

View File

@ -4,6 +4,7 @@ import { tracker } from "@follow/tracker"
import { omit } from "es-toolkit"
import { apiClient } from "../context"
import { invalidateEntriesQuery } from "../entry/hooks"
import { getFeedById } from "../feed/getter"
import { feedActions } from "../feed/store"
import { inboxActions } from "../inbox/store"
@ -585,6 +586,10 @@ class SubscriptionSyncService {
feedIds: folderFeedIds,
view: newView,
})
invalidateEntriesQuery({
views: [currentView, newView],
})
}
async renameCategory({

View File

@ -1,8 +1,8 @@
diff --git a/esm/main/ipc.js b/esm/main/ipc.js
index 8edfaf4660734f9120a6d5f5f806688c25a3a026..a692b1c16470b0dbed7c53e1c9aeff8a456f1c5a 100644
index b5d4478b20cac94e1e1e54900d616286aa9f7733..3639b802c2f4012e943ed2f3d0454fbf5eeda3e3 100644
--- a/esm/main/ipc.js
+++ b/esm/main/ipc.js
@@ -111,14 +111,6 @@ function configureProtocol(client, options) {
@@ -134,14 +134,6 @@ function configureProtocol(client, options) {
if (app.isReady()) {
throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired");
}
@ -18,10 +18,10 @@ index 8edfaf4660734f9120a6d5f5f806688c25a3a026..a692b1c16470b0dbed7c53e1c9aeff8a
app
.whenReady()
diff --git a/main/ipc.js b/main/ipc.js
index 01fc75bdf031b62195504cc0bf7055ebbf15b641..0519242ba5f391f9f618229044948feefeae1de8 100644
index 6866654fb79361ec90cc9b175a624d80c1e614b4..74494413854aba2acf777d641e356d0200b5a6d1 100644
--- a/main/ipc.js
+++ b/main/ipc.js
@@ -111,14 +111,6 @@ function configureProtocol(client, options) {
@@ -134,14 +134,6 @@ function configureProtocol(client, options) {
if (electron.app.isReady()) {
throw new Error("Sentry SDK should be initialized before the Electron app 'ready' event is fired");
}

View File

@ -31,7 +31,7 @@ patchedDependencies:
hash: 5b5ab1ba36e8c0d7ffee912ebf29c1a18bc101c9c661ceb1bb0bda3deaf4c667
path: patches/@pengx17__electron-forge-maker-appimage.patch
'@sentry/electron':
hash: b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb
hash: a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979
path: patches/@sentry__electron.patch
daisyui@4.12.24:
hash: d393ab1cbfbfcff21dce0796a59c2d8a37e2c6dd634a8ab476cbc67e47b93d9c
@ -356,7 +356,7 @@ importers:
version: 1.0.1
'@sentry/electron':
specifier: 6.8.0
version: 6.8.0(patch_hash=b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb)
version: 6.8.0(patch_hash=a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979)
builder-util-runtime:
specifier: 9.3.1
version: 9.3.1
@ -21940,7 +21940,7 @@ snapshots:
'@sentry/core@9.30.0': {}
'@sentry/electron@6.8.0(patch_hash=b5efa039abfa14f7833b762e8a7c1c3ae147fda22954d860ffa3387db9acf8eb)':
'@sentry/electron@6.8.0(patch_hash=a5a19cbba4427bc1e6f675a47170c6e03adc0fcec259258bfa07288185b5a979)':
dependencies:
'@sentry/browser': 9.26.0
'@sentry/core': 9.26.0