Skip to content

Commit afd5190

Browse files
committed
feat(saves): add saves to pocket future
1 parent 69a9ca0 commit afd5190

File tree

10 files changed

+316
-235
lines changed

10 files changed

+316
-235
lines changed

clients/pocket/app/[locale]/page.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
// API
2-
import { getHomeSlates, type HomeQueryResponse } from '@api/get-home-feed'
2+
import { getHomeSlates } from '@common/state/page-home/server'
33

44
// Constants
55
import { SUPPORTED_LOCALES } from '@common/localization'
66

77
// UI
88
import { Error } from '@ui/components/error'
9-
109
import { ItemArticle } from '@ui/components/item-article'
1110

1211
// Types
13-
import type { SlateWithRecIds } from '@api/get-home-feed'
12+
import type { SlateWithRecIds, HomeQueryResponse } from '@common/state/page-home/server'
1413

1514
export function generateStaticParams() {
1615
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
+33-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
11
import { SUPPORTED_LOCALES } from '@common/localization'
2-
import { Suspense } from 'react'
2+
3+
import { Error } from '@ui/components/error'
4+
import { ItemArticle } from '@ui/components/item-article'
5+
6+
// API
7+
import { getUserSaves, type UserSavesQueryResponse } from '@common/state/page-saves/server'
8+
import { SavedItemsSortBy, SavedItemsSortOrder, SavedItemStatusFilter } from '@common/types/pocket'
39

410
export function generateStaticParams() {
511
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
612
}
713

814
export default async function Saves({ params }: { params: Promise<{ locale: string }> }) {
915
const { locale } = await params
16+
17+
const sortBy = 'CREATED_AT' as SavedItemsSortBy.CreatedAt
18+
const sortOrder = 'DESC' as SavedItemsSortOrder.Desc
19+
const statuses = ['UNREAD' as SavedItemStatusFilter.Unread]
20+
21+
const response = await getUserSaves({
22+
pagination: { first: 30 },
23+
sort: { sortBy, sortOrder },
24+
filter: { statuses }
25+
})
26+
27+
const errorTitle = 'Well that’s not right... '
28+
if ('responseError' in response)
29+
return <Error title={errorTitle} message={response.responseError!} />
30+
31+
const { itemsById, savePageIds } = response as UserSavesQueryResponse
32+
1033
return (
1134
<div className="page-container">
12-
<Suspense>{locale}</Suspense>
35+
<section data-columns={3}>
36+
<div className="outer">
37+
<div className="grid" data-total={3}>
38+
{savePageIds.map((itemId: string) => (
39+
<ItemArticle key={itemId} item={itemsById[itemId]} />
40+
))}
41+
</div>
42+
</div>
43+
</section>
1344
</div>
1445
)
1546
}

common/state/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"author": "",
66
"dependencies": {
77
"@common/constants": "workspace:*",
8+
"@common/localization": "workspace:*",
89
"@common/utilities": "workspace:*",
910
"jose": "5.9.6",
1011
"next": "15.0.1",

clients/pocket/app/api/get-home-feed/index.ts common/state/page-home/server.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,15 @@ import { gql, pocketRequest } from '@common/utilities/pocket-request'
66
import { FRAGMENT_ITEM_PREVIEW } from '../_fragments/preview'
77

88
// Types
9+
import type { ResponseError } from '@common/types'
910
import type {
1011
CorpusSlate,
1112
CorpusRecommendation,
1213
CorpusSlateLineup,
1314
PocketMetadata
1415
} from '@common/types/pocket'
1516

16-
interface ResponseError {
17-
responseError?: string
18-
}
19-
17+
// Custom Types for this return
2018
export type SlateWithRecIds = {
2119
recIds: string[]
2220
} & Omit<CorpusSlate, 'recommendations'>

common/state/page-saves/server.ts

+147
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { getErrorMessage } from '@common/utilities/error-handler'
2+
import { gql, pocketRequest } from '@common/utilities/pocket-request'
3+
4+
import { getClaims, verifySession } from '../user-info/session'
5+
6+
import { FRAGMENT_ITEM_PREVIEW } from '../_fragments/preview'
7+
import { FRAGMENT_ITEM_SAVED_INFO } from '../_fragments/saved-info'
8+
9+
// Types
10+
import type { ResponseError } from '@common/types'
11+
import type {
12+
Item,
13+
PageInfo,
14+
PaginationInput,
15+
PocketMetadata,
16+
SavedItem,
17+
SavedItemConnection,
18+
SavedItemsFilter,
19+
SavedItemsPage,
20+
SavedItemsSort,
21+
User
22+
} from '@common/types/pocket'
23+
24+
// Custom Types for this return
25+
export interface UserSavesQueryResponse {
26+
itemsById: Record<string, PocketMetadata>
27+
saveDataById: Record<string, SavedItem>
28+
savePageIds: string[]
29+
savePageInfo: PageInfo & {
30+
totalCount: number
31+
}
32+
}
33+
34+
interface UserSavesArguments {
35+
pagination: PaginationInput
36+
sort?: SavedItemsSort
37+
filter?: SavedItemsFilter
38+
}
39+
40+
const getUserSavesQuery = gql`
41+
query UserSaves($pagination: PaginationInput, $sort: SavedItemsSort, $filter: SavedItemsFilter) {
42+
user {
43+
savedItems(pagination: $pagination, sort: $sort, filter: $filter) {
44+
totalCount
45+
pageInfo {
46+
hasNextPage
47+
endCursor
48+
hasPreviousPage
49+
startCursor
50+
}
51+
edges {
52+
node {
53+
item {
54+
... on Item {
55+
itemId
56+
preview {
57+
...ItemPreviewFragment
58+
}
59+
}
60+
}
61+
...ItemSavedFragment
62+
}
63+
}
64+
}
65+
}
66+
}
67+
${FRAGMENT_ITEM_PREVIEW}
68+
${FRAGMENT_ITEM_SAVED_INFO}
69+
`
70+
71+
/**
72+
* getUserSaves
73+
* ---
74+
* Returns users saves
75+
*/
76+
export async function getUserSaves(
77+
variables: UserSavesArguments
78+
): Promise<UserSavesQueryResponse | ResponseError> {
79+
try {
80+
const authResponse = await verifySession()
81+
82+
const { token, isAuthenticated } = authResponse
83+
84+
if (!isAuthenticated) throw new PageSavesError('User not authenticated')
85+
86+
const response = await pocketRequest<{ user: Pick<User, 'savedItems'> }>(
87+
{
88+
query: getUserSavesQuery,
89+
variables
90+
},
91+
token
92+
)
93+
94+
const savedItems = response?.user?.savedItems
95+
if (!savedItems) throw new PageSavesError('Issue retrieving user saves ')
96+
97+
const { edges, pageInfo, totalCount } = savedItems
98+
99+
const convertedEdges = edges
100+
?.map((edge) => {
101+
const { item, ...savedData } = edge?.node || {}
102+
103+
// We are gonna filter pending items out
104+
// ?? NOTE: Pending item was a future pattern to separate parser response
105+
// ?? from metadata. As it stands, this is not fully implemented, but we
106+
// ?? will still handle this case for type safety
107+
if (item?.__typename === 'PendingItem') return false
108+
109+
const { preview, itemId, shareId } = item as Item
110+
return { preview: { ...preview, itemId }, savedData }
111+
})
112+
.filter(Boolean)
113+
114+
const itemsById = convertedEdges.reduce((previous, current) => {
115+
const { preview, saveData } = current
116+
return { ...previous, [preview.itemId]: preview }
117+
}, {})
118+
119+
const saveDataById = convertedEdges.reduce((previous, current) => {
120+
const { preview, saveData } = current
121+
return { ...previous, [preview.itemId]: saveData }
122+
}, {})
123+
124+
const savePageIds = edges.map((edge) => edge?.node?.item?.itemId)
125+
126+
return {
127+
itemsById,
128+
saveDataById,
129+
savePageIds,
130+
savePageInfo: { ...pageInfo, totalCount }
131+
}
132+
} catch (error) {
133+
return { responseError: getErrorMessage(error) }
134+
}
135+
}
136+
137+
/**
138+
* UserRequestError
139+
* ---
140+
* Generic UserRequestError to make visibility more useful
141+
*/
142+
class PageSavesError extends Error {
143+
constructor(message?: string) {
144+
super(message)
145+
this.name = 'PageSavesError'
146+
}
147+
}

common/state/user-info/session.ts

+15-11
Original file line numberDiff line numberDiff line change
@@ -65,22 +65,26 @@ export async function verifySession(): Promise<Session> {
6565
// If the token states we are authenticated and not expired
6666
// AND we have an accessToken, let's refresh it.
6767
// NOTE: Access tokens do not expire on the server side
68-
if (isAuthenticated && accessToken && !isExpired) {
69-
const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days from now.
70-
storedCookies.set({
71-
expires,
72-
name: 'accessToken',
73-
value: accessToken,
74-
httpOnly: true,
75-
secure: true,
76-
path: '/'
77-
})
78-
}
68+
// !! IMPORTANT: This needs to happen in a server action. Need to better understand
69+
// !! How nextJS determines a server action. Most likely this will need to be moved
70+
// if (isAuthenticated && accessToken && !isExpired) {
71+
// const expires = Date.now() + 30 * 24 * 60 * 60 * 1000 // 30 days from now.
72+
// storedCookies.set({
73+
// expires,
74+
// name: 'accessToken',
75+
// value: accessToken,
76+
// httpOnly: true,
77+
// secure: true,
78+
// path: '/'
79+
// })
80+
// }
7981
if (!isExpired) return { token: bearerToken, isAuthenticated }
8082
}
83+
8184
const session: Session = await createSession()
8285
return session
8386
} catch (err) {
87+
console.log(err)
8488
return { token: undefined, isAuthenticated: false }
8589
}
8690
}

common/utilities/error-handler/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ResponseError } from '@common/types'
2+
13
/**
24
* https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
35
* ---
@@ -37,3 +39,7 @@ function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
3739
export function getErrorMessage(error: unknown) {
3840
return toErrorWithMessage(error).message
3941
}
42+
43+
export function isError<T extends object>(response: T | ResponseError): response is ResponseError {
44+
return 'responseError' in response
45+
}

0 commit comments

Comments
 (0)