@pyqdeck/community-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raiyan Hasan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # @pyqdeck/community-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed client for the PYQDeck Community API. It works in React Native (Expo, Hermes) and the browser. It has **no cache,
4
+ no Clerk and no React inside**: your app passes a `getToken()` function and owns caching, retries and optimistic updates
5
+ (for example with TanStack Query).
6
+
7
+ ```ts
8
+ import { createCommunityClient, communityKeys } from "@pyqdeck/community-sdk";
9
+
10
+ const community = createCommunityClient({
11
+ baseUrl: "https://community-api.pyqdeck.in",
12
+ community: "beu", // one client per community
13
+ getToken: async () => (await clerk.session?.getToken()) ?? null,
14
+ });
15
+
16
+ // Read
17
+ const feed = await community.posts.feed({ hashtag: "dsa", limit: 20 }); // { items, nextCursor, hasMore }
18
+ const next = await community.posts.feed({ cursor: feed.nextCursor! });
19
+
20
+ // Publish with images in one call (upload, check, create), with progress and cancel
21
+ const post = await community.publishPost({
22
+ body: "Dijkstra notes #dsa",
23
+ images: [{ uri, mimeType: "image/jpeg", size }],
24
+ onProgress: (p) => setProgress(p), // 0..1
25
+ signal: controller.signal,
26
+ });
27
+
28
+ // React with TanStack Query: keys always start with the community
29
+ useQuery({
30
+ queryKey: communityKeys.feed("beu", { hashtag: "dsa" }),
31
+ queryFn: ({ signal }) => community.posts.feed({ hashtag: "dsa" }, { signal }),
32
+ });
33
+ ```
34
+
35
+ ## Errors
36
+
37
+ Every failure is a `CommunityApiError` with `status` (0 means no answer from the server), a stable `code`
38
+ (`not_found`, `unauthorized`, `too_many_requests`, `unprocessable_entity`, `network_error`, `timeout`, `aborted`),
39
+ `retryAfterMs` for rate limits, and `requestId` to quote in bug reports. `message` is safe to show to students.
40
+
41
+ ## Good to know
42
+
43
+ - Every method takes `{ signal }` as its last argument.
44
+ - Responses contain only JSON-safe values (dates are ISO strings), so they can be persisted.
45
+ - Send an idempotency key (`publishPost` does this for you) so a retry after a timeout never creates a duplicate.
46
+ - The API surface lives at `/api/v1`. A breaking API change means a new major version of this package.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,181 @@
1
+ import { type HttpOptions, type RequestOptions } from "./http.js";
2
+ import type { Comment, CommunityInfo, LinkInfo, LinkedResource, LocalImage, Member, MediaItem, Notification, NotificationType, OwnMember, Page, PageParams, Post, ProfileUpdate, ReportGroup, ReportReason, ReportTarget, TrendingTag, UploadTicket } from "./types.js";
3
+ import { type FileUploader } from "./upload.js";
4
+ export interface CommunityClientOptions extends HttpOptions {
5
+ /** The community this client talks to (`beu`, `gate`, ...). Added to every path; one client per app (IR-SDK-12). */
6
+ community: string;
7
+ /** Override how bytes reach the storage provider (defaults to XMLHttpRequest with progress). */
8
+ uploadFile?: FileUploader;
9
+ }
10
+ export interface CreatePostInput {
11
+ body?: string;
12
+ mediaIds?: string[];
13
+ linkedResource?: LinkedResource;
14
+ }
15
+ export interface EditPostInput {
16
+ body?: string;
17
+ mediaIds?: string[];
18
+ linkedResource?: LinkedResource | null;
19
+ }
20
+ export interface PublishPostInput {
21
+ body?: string;
22
+ images?: LocalImage[];
23
+ linkedResource?: LinkedResource;
24
+ /** 0 to 1: image bytes first, then the final publish step. */
25
+ onProgress?: (progress: number) => void;
26
+ signal?: AbortSignal;
27
+ /** Reuse the same key when retrying so a lost response cannot create a second post. */
28
+ idempotencyKey?: string;
29
+ }
30
+ export interface FeedParams extends PageParams {
31
+ hashtag?: string;
32
+ /** Search text (2-100 characters). */
33
+ q?: string;
34
+ }
35
+ /** A new random key for `Idempotency-Key`. */
36
+ export declare const newIdempotencyKey: () => string;
37
+ export declare function createCommunityClient(options: CommunityClientOptions): {
38
+ community: string;
39
+ info: (o?: RequestOptions) => Promise<CommunityInfo>;
40
+ posts: {
41
+ feed: (p?: FeedParams, o?: RequestOptions) => Promise<Page<Post>>;
42
+ get: (idOrSlug: string, o?: RequestOptions) => Promise<Post>;
43
+ create: (input: CreatePostInput, o?: RequestOptions & {
44
+ idempotencyKey?: string;
45
+ }) => Promise<Post>;
46
+ edit: (id: string, input: EditPostInput, o?: RequestOptions) => Promise<Post>;
47
+ delete: (id: string, o?: RequestOptions) => Promise<undefined>;
48
+ like: (id: string, o?: RequestOptions) => Promise<{
49
+ likesCount: number;
50
+ isLiked: boolean;
51
+ }>;
52
+ unlike: (id: string, o?: RequestOptions) => Promise<{
53
+ likesCount: number;
54
+ isLiked: boolean;
55
+ }>;
56
+ bookmark: (id: string, o?: RequestOptions) => Promise<{
57
+ bookmarksCount: number;
58
+ isBookmarked: boolean;
59
+ }>;
60
+ unbookmark: (id: string, o?: RequestOptions) => Promise<{
61
+ bookmarksCount: number;
62
+ isBookmarked: boolean;
63
+ }>;
64
+ hide: (id: string, o?: RequestOptions) => Promise<undefined>;
65
+ unhide: (id: string, o?: RequestOptions) => Promise<undefined>;
66
+ bookmarks: (p?: PageParams, o?: RequestOptions) => Promise<Page<Post>>;
67
+ byMember: (username: string, p?: PageParams, o?: RequestOptions) => Promise<Page<Post>>;
68
+ trending: (o?: RequestOptions) => Promise<TrendingTag[]>;
69
+ moderate: (id: string, action: "approve" | "remove" | "reject_edit", reason?: ReportReason, o?: RequestOptions) => Promise<Post>;
70
+ queue: (p?: PageParams, o?: RequestOptions) => Promise<Page<Post>>;
71
+ };
72
+ comments: {
73
+ list: (postId: string, p?: PageParams, o?: RequestOptions) => Promise<Page<Comment>>;
74
+ replies: (commentId: string, p?: PageParams, o?: RequestOptions) => Promise<Page<Comment>>;
75
+ /** `parentId` is any comment of the thread; the server attaches the reply to the thread's top-level comment. */
76
+ create: (postId: string, input: {
77
+ body: string;
78
+ parentId?: string;
79
+ }, o?: RequestOptions & {
80
+ idempotencyKey?: string;
81
+ }) => Promise<Comment>;
82
+ edit: (id: string, body: string, o?: RequestOptions) => Promise<Comment>;
83
+ delete: (id: string, o?: RequestOptions) => Promise<undefined>;
84
+ like: (id: string, o?: RequestOptions) => Promise<{
85
+ likesCount: number;
86
+ isLiked: boolean;
87
+ }>;
88
+ unlike: (id: string, o?: RequestOptions) => Promise<{
89
+ likesCount: number;
90
+ isLiked: boolean;
91
+ }>;
92
+ moderate: (id: string, action: "approve" | "remove" | "reject_edit", reason?: ReportReason, o?: RequestOptions) => Promise<Comment>;
93
+ queue: (p?: PageParams, o?: RequestOptions) => Promise<Page<Comment>>;
94
+ };
95
+ members: {
96
+ me: (o?: RequestOptions) => Promise<OwnMember>;
97
+ updateMe: (patch: ProfileUpdate, o?: RequestOptions) => Promise<OwnMember>;
98
+ get: (username: string, o?: RequestOptions) => Promise<Member>;
99
+ search: (q: string, limit?: number, o?: RequestOptions) => Promise<Member[]>;
100
+ followers: (username: string, p?: PageParams, o?: RequestOptions) => Promise<Page<Member>>;
101
+ following: (username: string, p?: PageParams, o?: RequestOptions) => Promise<Page<Member>>;
102
+ follow: (username: string, o?: RequestOptions) => Promise<{
103
+ isFollowing: boolean;
104
+ followersCount: number;
105
+ }>;
106
+ unfollow: (username: string, o?: RequestOptions) => Promise<{
107
+ isFollowing: boolean;
108
+ followersCount: number;
109
+ }>;
110
+ block: (username: string, o?: RequestOptions) => Promise<undefined>;
111
+ unblock: (username: string, o?: RequestOptions) => Promise<undefined>;
112
+ blocked: (p?: PageParams, o?: RequestOptions) => Promise<Page<Member>>;
113
+ suspend: (username: string, reason: string, until?: string, o?: RequestOptions) => Promise<undefined>;
114
+ unsuspend: (username: string, o?: RequestOptions) => Promise<undefined>;
115
+ setRole: (username: string, role: "member" | "moderator" | "admin", o?: RequestOptions) => Promise<{
116
+ username: string;
117
+ role: string;
118
+ }>;
119
+ };
120
+ notifications: {
121
+ list: (p?: PageParams & {
122
+ unread?: boolean;
123
+ type?: NotificationType;
124
+ }, o?: RequestOptions) => Promise<Page<Notification>>;
125
+ unreadCount: (o?: RequestOptions) => Promise<number>;
126
+ markRead: (id: string, o?: RequestOptions) => Promise<undefined>;
127
+ markAllRead: (o?: RequestOptions) => Promise<undefined>;
128
+ dismiss: (id: string, o?: RequestOptions) => Promise<undefined>;
129
+ };
130
+ reports: {
131
+ create: (input: {
132
+ targetType: ReportTarget;
133
+ targetId: string;
134
+ reason: ReportReason;
135
+ note?: string;
136
+ }, o?: RequestOptions) => Promise<{
137
+ received: boolean;
138
+ duplicate: boolean;
139
+ }>;
140
+ queue: (p?: {
141
+ page?: number;
142
+ limit?: number;
143
+ }, o?: RequestOptions) => Promise<ReportGroup[]>;
144
+ resolve: (input: {
145
+ targetType: ReportTarget;
146
+ targetId: string;
147
+ outcome: "actioned" | "dismissed";
148
+ remove?: boolean;
149
+ }, o?: RequestOptions) => Promise<{
150
+ closed: number;
151
+ }>;
152
+ };
153
+ links: {
154
+ /** Destination host and safety verdict, so the app can show its own warning before opening a link. */
155
+ resolve: (code: string, o?: RequestOptions) => Promise<LinkInfo>;
156
+ block: (code: string, o?: RequestOptions) => Promise<undefined>;
157
+ unblock: (code: string, o?: RequestOptions) => Promise<undefined>;
158
+ };
159
+ media: {
160
+ requestUploads: (files: Array<{
161
+ mimeType: string;
162
+ size: number;
163
+ }>, o?: RequestOptions) => Promise<{
164
+ id: string;
165
+ upload: UploadTicket;
166
+ }[]>;
167
+ complete: (id: string, o?: RequestOptions) => Promise<MediaItem>;
168
+ remove: (id: string, o?: RequestOptions) => Promise<undefined>;
169
+ /**
170
+ * The whole direct-upload sequence as one call: ask for tickets, send each image straight to the storage provider,
171
+ * then have the server confirm and check each one. Returns media ids ready to attach (IR-SDK-4). Uploads that were
172
+ * started are removed again if anything fails or the call is cancelled.
173
+ */
174
+ uploadImages: (images: LocalImage[], opts?: {
175
+ onProgress?: (fraction: number) => void;
176
+ signal?: AbortSignal;
177
+ }) => Promise<MediaItem[]>;
178
+ };
179
+ publishPost: (input: PublishPostInput) => Promise<Post>;
180
+ };
181
+ export type CommunityClient = ReturnType<typeof createCommunityClient>;
package/dist/client.js ADDED
@@ -0,0 +1,175 @@
1
+ import { CommunityApiError } from "./errors.js";
2
+ import { Http } from "./http.js";
3
+ import { xhrUploader } from "./upload.js";
4
+ const toPage = (env) => ({
5
+ items: env.data,
6
+ nextCursor: env.pagination?.nextCursor ?? null,
7
+ hasMore: env.pagination?.hasMore ?? false,
8
+ });
9
+ /** A new random key for `Idempotency-Key`. */
10
+ export const newIdempotencyKey = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
11
+ export function createCommunityClient(options) {
12
+ const http = new Http(options);
13
+ const uploadFile = options.uploadFile ?? xhrUploader;
14
+ const root = `/api/v1/communities/${encodeURIComponent(options.community)}`;
15
+ const enc = encodeURIComponent;
16
+ const get = (path, query, o) => http.call({ method: "GET", path: root + path, query, ...o });
17
+ const send = (method, path, body, o) => http.call({ method, path: root + path, body, ...o });
18
+ const page = async (path, query, o) => toPage(await get(path, query, o));
19
+ // ---- posts ----
20
+ const posts = {
21
+ feed: (p = {}, o) => page("/posts", { ...p }, o),
22
+ get: async (idOrSlug, o) => (await get(`/posts/${enc(idOrSlug)}`, undefined, o)).data,
23
+ create: async (input, o) => (await send("POST", "/posts", input, {
24
+ ...o,
25
+ headers: { ...o?.headers, ...(o?.idempotencyKey && { "Idempotency-Key": o.idempotencyKey }) },
26
+ })).data,
27
+ edit: async (id, input, o) => (await send("PATCH", `/posts/${enc(id)}`, input, o)).data,
28
+ delete: async (id, o) => void (await send("DELETE", `/posts/${enc(id)}`, undefined, o)),
29
+ like: async (id, o) => (await send("PUT", `/posts/${enc(id)}/like`, undefined, o)).data,
30
+ unlike: async (id, o) => (await send("DELETE", `/posts/${enc(id)}/like`, undefined, o)).data,
31
+ bookmark: async (id, o) => (await send("PUT", `/posts/${enc(id)}/bookmark`, undefined, o))
32
+ .data,
33
+ unbookmark: async (id, o) => (await send("DELETE", `/posts/${enc(id)}/bookmark`, undefined, o)).data,
34
+ hide: async (id, o) => void (await send("PUT", `/posts/${enc(id)}/hide`, undefined, o)),
35
+ unhide: async (id, o) => void (await send("DELETE", `/posts/${enc(id)}/hide`, undefined, o)),
36
+ bookmarks: (p = {}, o) => page("/members/me/bookmarks", { ...p }, o),
37
+ byMember: (username, p = {}, o) => page(`/members/${enc(username)}/posts`, { ...p }, o),
38
+ trending: async (o) => (await get("/hashtags/trending", undefined, o)).data,
39
+ moderate: async (id, action, reason, o) => (await send("POST", `/posts/${enc(id)}/moderate`, { action, reason }, o)).data,
40
+ queue: (p = {}, o) => page("/posts/queue", { ...p }, o),
41
+ };
42
+ // ---- comments ----
43
+ const comments = {
44
+ list: (postId, p = {}, o) => page(`/posts/${enc(postId)}/comments`, { ...p }, o),
45
+ replies: (commentId, p = {}, o) => page(`/comments/${enc(commentId)}/replies`, { ...p }, o),
46
+ /** `parentId` is any comment of the thread; the server attaches the reply to the thread's top-level comment. */
47
+ create: async (postId, input, o) => (await send("POST", `/posts/${enc(postId)}/comments`, input, {
48
+ ...o,
49
+ headers: { ...o?.headers, ...(o?.idempotencyKey && { "Idempotency-Key": o.idempotencyKey }) },
50
+ })).data,
51
+ edit: async (id, body, o) => (await send("PATCH", `/comments/${enc(id)}`, { body }, o)).data,
52
+ delete: async (id, o) => void (await send("DELETE", `/comments/${enc(id)}`, undefined, o)),
53
+ like: async (id, o) => (await send("PUT", `/comments/${enc(id)}/like`, undefined, o)).data,
54
+ unlike: async (id, o) => (await send("DELETE", `/comments/${enc(id)}/like`, undefined, o)).data,
55
+ moderate: async (id, action, reason, o) => (await send("POST", `/comments/${enc(id)}/moderate`, { action, reason }, o)).data,
56
+ queue: (p = {}, o) => page("/comments/queue", { ...p }, o),
57
+ };
58
+ // ---- members ----
59
+ const members = {
60
+ me: async (o) => (await get("/members/me", undefined, o)).data,
61
+ updateMe: async (patch, o) => (await send("PATCH", "/members/me", patch, o)).data,
62
+ get: async (username, o) => (await get(`/members/${enc(username)}`, undefined, o)).data,
63
+ search: async (q, limit, o) => (await get("/members/search", { q, limit }, o)).data,
64
+ followers: (username, p = {}, o) => page(`/members/${enc(username)}/followers`, { ...p }, o),
65
+ following: (username, p = {}, o) => page(`/members/${enc(username)}/following`, { ...p }, o),
66
+ follow: async (username, o) => (await send("PUT", `/members/${enc(username)}/follow`, undefined, o)).data,
67
+ unfollow: async (username, o) => (await send("DELETE", `/members/${enc(username)}/follow`, undefined, o)).data,
68
+ block: async (username, o) => void (await send("PUT", `/members/${enc(username)}/block`, undefined, o)),
69
+ unblock: async (username, o) => void (await send("DELETE", `/members/${enc(username)}/block`, undefined, o)),
70
+ blocked: (p = {}, o) => page("/members/me/blocked", { ...p }, o),
71
+ suspend: async (username, reason, until, o) => void (await send("PUT", `/members/${enc(username)}/suspend`, { reason, until }, o)),
72
+ unsuspend: async (username, o) => void (await send("DELETE", `/members/${enc(username)}/suspend`, undefined, o)),
73
+ setRole: async (username, role, o) => (await send("PUT", `/members/${enc(username)}/role`, { role }, o)).data,
74
+ };
75
+ // ---- notifications ----
76
+ const notifications = {
77
+ list: (p = {}, o) => page("/notifications", { ...p }, o),
78
+ unreadCount: async (o) => (await get("/notifications/unread-count", undefined, o)).data.count,
79
+ markRead: async (id, o) => void (await send("PATCH", `/notifications/${enc(id)}`, { read: true }, o)),
80
+ markAllRead: async (o) => void (await send("POST", "/notifications/read-all", undefined, o)),
81
+ dismiss: async (id, o) => void (await send("DELETE", `/notifications/${enc(id)}`, undefined, o)),
82
+ };
83
+ // ---- reports and links ----
84
+ const reports = {
85
+ create: async (input, o) => (await send("POST", "/reports", input, o)).data,
86
+ queue: async (p = {}, o) => (await get("/reports", { ...p }, o)).data,
87
+ resolve: async (input, o) => (await send("POST", "/reports/resolve", input, o)).data,
88
+ };
89
+ const links = {
90
+ /** Destination host and safety verdict, so the app can show its own warning before opening a link. */
91
+ resolve: async (code, o) => (await get(`/links/${enc(code)}`, undefined, o)).data,
92
+ block: async (code, o) => void (await send("POST", `/links/${enc(code)}/block`, undefined, o)),
93
+ unblock: async (code, o) => void (await send("DELETE", `/links/${enc(code)}/block`, undefined, o)),
94
+ };
95
+ // ---- media ----
96
+ const media = {
97
+ requestUploads: async (files, o) => (await send("POST", "/media/uploads", { files }, o)).data,
98
+ complete: async (id, o) => (await send("POST", `/media/${enc(id)}/complete`, undefined, o)).data,
99
+ remove: async (id, o) => void (await send("DELETE", `/media/${enc(id)}`, undefined, o)),
100
+ /**
101
+ * The whole direct-upload sequence as one call: ask for tickets, send each image straight to the storage provider,
102
+ * then have the server confirm and check each one. Returns media ids ready to attach (IR-SDK-4). Uploads that were
103
+ * started are removed again if anything fails or the call is cancelled.
104
+ */
105
+ uploadImages: async (images, opts = {}) => {
106
+ if (images.length === 0)
107
+ return [];
108
+ const started = [];
109
+ try {
110
+ const tickets = await media.requestUploads(images.map((i) => ({ mimeType: i.mimeType, size: i.size })), { signal: opts.signal });
111
+ started.push(...tickets.map((t) => t.id));
112
+ const total = images.reduce((sum, i) => sum + i.size, 0) || 1;
113
+ const sent = new Array(images.length).fill(0);
114
+ const report = () => opts.onProgress?.(sent.reduce((a, b) => a + b, 0) / total);
115
+ await Promise.all(tickets.map((t, i) => uploadFile({
116
+ ticket: t.upload,
117
+ image: images[i],
118
+ signal: opts.signal,
119
+ onProgress: (loaded, size) => {
120
+ sent[i] = Math.min(images[i].size, size ? (loaded / size) * images[i].size : loaded);
121
+ report();
122
+ },
123
+ }).then(() => {
124
+ sent[i] = images[i].size;
125
+ report();
126
+ })));
127
+ const done = [];
128
+ for (const t of tickets)
129
+ done.push(await media.complete(t.id, { signal: opts.signal }));
130
+ return done;
131
+ }
132
+ catch (err) {
133
+ await Promise.allSettled(started.map((id) => media.remove(id)));
134
+ throw err;
135
+ }
136
+ },
137
+ };
138
+ /**
139
+ * Publishes a post with its images in one operation, with progress and cancellation (IR-SDK-3). Images count for
140
+ * the first 90% of progress; the final create call for the rest. The server answers 422 when moderation refuses
141
+ * the text, and returns a `pending` post when it is held for review.
142
+ */
143
+ const publishPost = async (input) => {
144
+ const key = input.idempotencyKey ?? newIdempotencyKey();
145
+ input.onProgress?.(0);
146
+ const uploaded = await media.uploadImages(input.images ?? [], {
147
+ signal: input.signal,
148
+ onProgress: (f) => input.onProgress?.(f * 0.9),
149
+ });
150
+ try {
151
+ const post = await posts.create({ body: input.body, mediaIds: uploaded.map((m) => m.id), linkedResource: input.linkedResource }, { signal: input.signal, idempotencyKey: key });
152
+ input.onProgress?.(1);
153
+ return post;
154
+ }
155
+ catch (err) {
156
+ // A refused or cancelled post must not leave its images behind. A network failure keeps them so a retry can reuse them.
157
+ if (!(err instanceof CommunityApiError) || err.status !== 0) {
158
+ await Promise.allSettled(uploaded.map((m) => media.remove(m.id)));
159
+ }
160
+ throw err;
161
+ }
162
+ };
163
+ return {
164
+ community: options.community,
165
+ info: async (o) => (await get("/", undefined, o)).data,
166
+ posts,
167
+ comments,
168
+ members,
169
+ notifications,
170
+ reports,
171
+ links,
172
+ media,
173
+ publishPost,
174
+ };
175
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every failure from the API (or the network) arrives as one of these, so the app can branch on `status` and `code`
3
+ * without parsing messages. `message` is written for students and safe to show.
4
+ */
5
+ export declare class CommunityApiError extends Error {
6
+ /** HTTP status, or 0 when the request never got an answer (offline, timeout, aborted). */
7
+ readonly status: number;
8
+ /** Stable code such as `not_found`, `too_many_requests`, `unprocessable_entity`, `network_error`, `aborted`. */
9
+ readonly code: string;
10
+ /** From the Retry-After header on 429/503; null otherwise. */
11
+ readonly retryAfterMs: number | null;
12
+ readonly requestId: string | null;
13
+ /** Field errors for validation failures: `{ body: ["Required"] }`. */
14
+ readonly details: unknown;
15
+ readonly name = "CommunityApiError";
16
+ constructor(message: string,
17
+ /** HTTP status, or 0 when the request never got an answer (offline, timeout, aborted). */
18
+ status: number,
19
+ /** Stable code such as `not_found`, `too_many_requests`, `unprocessable_entity`, `network_error`, `aborted`. */
20
+ code: string,
21
+ /** From the Retry-After header on 429/503; null otherwise. */
22
+ retryAfterMs?: number | null, requestId?: string | null,
23
+ /** Field errors for validation failures: `{ body: ["Required"] }`. */
24
+ details?: unknown);
25
+ get isNetworkError(): boolean;
26
+ get isUnauthorized(): boolean;
27
+ get isRateLimited(): boolean;
28
+ /** Content was refused by moderation or link checks. */
29
+ get isRefused(): boolean;
30
+ }
31
+ export declare const isAbortError: (err: unknown) => err is CommunityApiError;
package/dist/errors.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Every failure from the API (or the network) arrives as one of these, so the app can branch on `status` and `code`
3
+ * without parsing messages. `message` is written for students and safe to show.
4
+ */
5
+ export class CommunityApiError extends Error {
6
+ constructor(message,
7
+ /** HTTP status, or 0 when the request never got an answer (offline, timeout, aborted). */
8
+ status,
9
+ /** Stable code such as `not_found`, `too_many_requests`, `unprocessable_entity`, `network_error`, `aborted`. */
10
+ code,
11
+ /** From the Retry-After header on 429/503; null otherwise. */
12
+ retryAfterMs = null, requestId = null,
13
+ /** Field errors for validation failures: `{ body: ["Required"] }`. */
14
+ details = undefined) {
15
+ super(message);
16
+ this.status = status;
17
+ this.code = code;
18
+ this.retryAfterMs = retryAfterMs;
19
+ this.requestId = requestId;
20
+ this.details = details;
21
+ this.name = "CommunityApiError";
22
+ }
23
+ get isNetworkError() {
24
+ return this.status === 0;
25
+ }
26
+ get isUnauthorized() {
27
+ return this.status === 401;
28
+ }
29
+ get isRateLimited() {
30
+ return this.status === 429;
31
+ }
32
+ /** Content was refused by moderation or link checks. */
33
+ get isRefused() {
34
+ return this.status === 422;
35
+ }
36
+ }
37
+ export const isAbortError = (err) => err instanceof CommunityApiError && err.code === "aborted";
package/dist/http.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export interface HttpOptions {
2
+ /** Origin of the Community API, e.g. https://community-api.pyqdeck.in. Separate from the main API's origin. */
3
+ baseUrl: string;
4
+ /** The same Clerk token source the app's main API client uses. Return null when signed out. */
5
+ getToken: () => Promise<string | null>;
6
+ fetch?: typeof fetch;
7
+ /** Per-request timeout. Default 15 s. Implemented with AbortController: Hermes has no AbortSignal.timeout. */
8
+ timeoutMs?: number;
9
+ }
10
+ export interface RequestOptions {
11
+ signal?: AbortSignal;
12
+ headers?: Record<string, string>;
13
+ }
14
+ interface Call extends RequestOptions {
15
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
16
+ path: string;
17
+ query?: Record<string, string | number | boolean | undefined | null>;
18
+ body?: unknown;
19
+ }
20
+ export interface Envelope<T> {
21
+ data: T;
22
+ pagination?: {
23
+ nextCursor?: string | null;
24
+ hasMore?: boolean;
25
+ };
26
+ }
27
+ export declare class Http {
28
+ private readonly options;
29
+ private readonly doFetch;
30
+ constructor(options: HttpOptions);
31
+ get baseUrl(): string;
32
+ getToken(): Promise<string | null>;
33
+ call<T>(call: Call): Promise<Envelope<T>>;
34
+ private parse;
35
+ }
36
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,81 @@
1
+ /// <reference lib="dom" />
2
+ import { CommunityApiError } from "./errors.js";
3
+ const DEFAULT_TIMEOUT_MS = 15000;
4
+ export class Http {
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.doFetch = options.fetch ?? ((...args) => fetch(...args));
8
+ }
9
+ get baseUrl() {
10
+ return this.options.baseUrl.replace(/\/+$/, "");
11
+ }
12
+ getToken() {
13
+ return this.options.getToken();
14
+ }
15
+ async call(call) {
16
+ const url = new URL(this.baseUrl + call.path);
17
+ for (const [key, value] of Object.entries(call.query ?? {})) {
18
+ if (value !== undefined && value !== null && value !== "")
19
+ url.searchParams.set(key, String(value));
20
+ }
21
+ // Sends no Authorization header when signed out, so the server answers with its own 401.
22
+ const token = await this.options.getToken().catch(() => null);
23
+ const headers = {
24
+ Accept: "application/json",
25
+ ...(call.body !== undefined && { "Content-Type": "application/json" }),
26
+ ...(token && { Authorization: `Bearer ${token}` }),
27
+ ...call.headers,
28
+ };
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
31
+ const onOuterAbort = () => controller.abort();
32
+ if (call.signal) {
33
+ if (call.signal.aborted)
34
+ controller.abort();
35
+ else
36
+ call.signal.addEventListener("abort", onOuterAbort, { once: true });
37
+ }
38
+ if (call.signal?.aborted) {
39
+ clearTimeout(timer);
40
+ throw new CommunityApiError("Request cancelled", 0, "aborted");
41
+ }
42
+ let response;
43
+ try {
44
+ response = await this.doFetch(url.toString(), {
45
+ method: call.method,
46
+ headers,
47
+ body: call.body === undefined ? undefined : JSON.stringify(call.body),
48
+ signal: controller.signal,
49
+ });
50
+ }
51
+ catch {
52
+ const timedOut = !call.signal?.aborted && controller.signal.aborted;
53
+ if (call.signal?.aborted)
54
+ throw new CommunityApiError("Request cancelled", 0, "aborted");
55
+ throw new CommunityApiError(timedOut
56
+ ? "The request took too long. Check your connection and try again."
57
+ : "Could not reach the server. Check your connection.", 0, timedOut ? "timeout" : "network_error");
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ call.signal?.removeEventListener("abort", onOuterAbort);
62
+ }
63
+ return this.parse(response);
64
+ }
65
+ async parse(response) {
66
+ if (response.status === 204)
67
+ return { data: undefined };
68
+ let json;
69
+ try {
70
+ json = await response.json();
71
+ }
72
+ catch {
73
+ json = null;
74
+ }
75
+ if (!response.ok || !json || json.success === false) {
76
+ const retryAfter = Number(response.headers.get("retry-after"));
77
+ throw new CommunityApiError(json?.message ?? `Something went wrong (${response.status})`, response.status, json?.code ?? `http_${response.status}`, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : null, json?.requestId ?? response.headers.get("x-request-id"), json?.details);
78
+ }
79
+ return { data: json.data, pagination: json.pagination };
80
+ }
81
+ }
@@ -0,0 +1,7 @@
1
+ export { createCommunityClient, newIdempotencyKey } from "./client.js";
2
+ export type { CommunityClient, CommunityClientOptions, CreatePostInput, EditPostInput, FeedParams, PublishPostInput, } from "./client.js";
3
+ export { CommunityApiError, isAbortError } from "./errors.js";
4
+ export { communityKeys } from "./keys.js";
5
+ export { xhrUploader } from "./upload.js";
6
+ export type { FileUploader } from "./upload.js";
7
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createCommunityClient, newIdempotencyKey } from "./client.js";
2
+ export { CommunityApiError, isAbortError } from "./errors.js";
3
+ export { communityKeys } from "./keys.js";
4
+ export { xhrUploader } from "./upload.js";
5
+ export * from "./types.js";
package/dist/keys.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Query-key factories for TanStack Query. Every key starts with the community, so the caches of different PYQDeck
3
+ * apps can never mix, and `invalidateQueries({ queryKey: communityKeys.all("beu") })` clears one community cleanly.
4
+ * Keys hold only plain JSON, so they persist with the app's AsyncStorage persister.
5
+ */
6
+ type Filters = Record<string, string | number | boolean | null | undefined>;
7
+ export declare const communityKeys: {
8
+ all: (community: string) => readonly ["community", string];
9
+ info: (community: string) => readonly ["community", string, "info"];
10
+ feeds: (community: string) => readonly ["community", string, "feed"];
11
+ feed: (community: string, filters?: Filters) => readonly ["community", string, "feed", Filters];
12
+ post: (community: string, idOrSlug: string) => readonly ["community", string, "post", string];
13
+ comments: (community: string, postId: string) => readonly ["community", string, "comments", string];
14
+ replies: (community: string, commentId: string) => readonly ["community", string, "replies", string];
15
+ me: (community: string) => readonly ["community", string, "me"];
16
+ member: (community: string, username: string) => readonly ["community", string, "member", string];
17
+ memberPosts: (community: string, username: string) => readonly ["community", string, "member-posts", string];
18
+ bookmarks: (community: string) => readonly ["community", string, "bookmarks"];
19
+ notifications: (community: string, filters?: Filters) => readonly ["community", string, "notifications", Filters];
20
+ unreadCount: (community: string) => readonly ["community", string, "unread-count"];
21
+ trending: (community: string) => readonly ["community", string, "trending"];
22
+ search: (community: string, q: string) => readonly ["community", string, "search", string];
23
+ link: (community: string, code: string) => readonly ["community", string, "link", string];
24
+ };
25
+ export {};
package/dist/keys.js ADDED
@@ -0,0 +1,18 @@
1
+ export const communityKeys = {
2
+ all: (community) => ["community", community],
3
+ info: (community) => [...communityKeys.all(community), "info"],
4
+ feeds: (community) => [...communityKeys.all(community), "feed"],
5
+ feed: (community, filters = {}) => [...communityKeys.feeds(community), filters],
6
+ post: (community, idOrSlug) => [...communityKeys.all(community), "post", idOrSlug],
7
+ comments: (community, postId) => [...communityKeys.all(community), "comments", postId],
8
+ replies: (community, commentId) => [...communityKeys.all(community), "replies", commentId],
9
+ me: (community) => [...communityKeys.all(community), "me"],
10
+ member: (community, username) => [...communityKeys.all(community), "member", username],
11
+ memberPosts: (community, username) => [...communityKeys.all(community), "member-posts", username],
12
+ bookmarks: (community) => [...communityKeys.all(community), "bookmarks"],
13
+ notifications: (community, filters = {}) => [...communityKeys.all(community), "notifications", filters],
14
+ unreadCount: (community) => [...communityKeys.all(community), "unread-count"],
15
+ trending: (community) => [...communityKeys.all(community), "trending"],
16
+ search: (community, q) => [...communityKeys.all(community), "search", q],
17
+ link: (community, code) => [...communityKeys.all(community), "link", code],
18
+ };
@@ -0,0 +1,208 @@
1
+ /** Shapes returned by the Community API. Dates are ISO strings so responses can be persisted as plain JSON. */
2
+ export type Role = "member" | "moderator" | "admin";
3
+ export type PostStatus = "published" | "pending" | "rejected" | "deleted";
4
+ export type CommentStatus = PostStatus;
5
+ export interface Author {
6
+ /** null only for a deleted member who has no row left to point at. */
7
+ id: string | null;
8
+ /** null for deleted members, who render as "Deleted user". */
9
+ username: string | null;
10
+ displayName: string;
11
+ avatarUrl: string;
12
+ headline: string;
13
+ role: Role;
14
+ }
15
+ export interface LinkRef {
16
+ code: string;
17
+ /** What readers see in the text, e.g. https://go.pyqdeck.in/aB3dE5gH */
18
+ shortUrl: string;
19
+ /** Destination host, to show next to the link. */
20
+ host: string;
21
+ /** Only present on your own content. */
22
+ clicks?: number;
23
+ }
24
+ export interface PostMedia {
25
+ id: string;
26
+ url: string;
27
+ }
28
+ export interface LinkedResource {
29
+ type: "pyq" | "topic";
30
+ title: string;
31
+ subtitle?: string;
32
+ subjectId?: string;
33
+ semesterId?: string;
34
+ year?: number;
35
+ questionId?: string;
36
+ topicId?: string;
37
+ }
38
+ export interface Post {
39
+ id: string;
40
+ slug: string;
41
+ author: Author;
42
+ /** The text readers see, with short links substituted. */
43
+ body: string;
44
+ hashtags: string[];
45
+ media: PostMedia[];
46
+ linkedResource: LinkedResource | null;
47
+ links: LinkRef[];
48
+ status: PostStatus;
49
+ likesCount: number;
50
+ commentsCount: number;
51
+ bookmarksCount: number;
52
+ isLiked: boolean;
53
+ isBookmarked: boolean;
54
+ isOwnPost: boolean;
55
+ editedAt: string | null;
56
+ createdAt: string;
57
+ /** The text as typed, for the edit form. */
58
+ rawBody?: string;
59
+ heldReason?: "moderation" | "reports" | null;
60
+ removedReason?: string | null;
61
+ hasPendingEdit?: boolean;
62
+ }
63
+ export interface Comment {
64
+ id: string;
65
+ postId: string;
66
+ /** The thread's top-level comment; null for a top-level comment itself. */
67
+ parentId: string | null;
68
+ author: Author;
69
+ replyTo: {
70
+ id: string | null;
71
+ username: string | null;
72
+ displayName: string;
73
+ } | null;
74
+ body: string;
75
+ links: LinkRef[];
76
+ status: CommentStatus;
77
+ likesCount: number;
78
+ repliesCount: number;
79
+ isLiked: boolean;
80
+ isOwn: boolean;
81
+ editedAt: string | null;
82
+ createdAt: string;
83
+ rawBody?: string;
84
+ hasPendingEdit?: boolean;
85
+ }
86
+ export interface Member extends Author {
87
+ coverUrl: string;
88
+ bio: string;
89
+ location: string;
90
+ github: string;
91
+ linkedin: string;
92
+ twitter: string;
93
+ website: string;
94
+ websiteLink: LinkRef | null;
95
+ skills: string[];
96
+ attributes: Record<string, string | number | boolean | null>;
97
+ followersCount: number;
98
+ followingCount: number;
99
+ postsCount: number;
100
+ upvotesCount: number;
101
+ joinedAt: string;
102
+ isFollowing?: boolean;
103
+ }
104
+ export interface OwnMember extends Member {
105
+ email: string;
106
+ status: "active" | "suspended" | "deleted";
107
+ suspendedUntil: string | null;
108
+ isPlatformAdmin: boolean;
109
+ }
110
+ export interface ProfileUpdate {
111
+ username?: string;
112
+ displayName?: string;
113
+ headline?: string;
114
+ bio?: string;
115
+ location?: string;
116
+ github?: string;
117
+ linkedin?: string;
118
+ twitter?: string;
119
+ website?: string;
120
+ skills?: string[];
121
+ attributes?: Record<string, string | number | boolean | null>;
122
+ /** Media ids from `media.uploadImages`; null removes the image. */
123
+ avatarMediaId?: string | null;
124
+ coverMediaId?: string | null;
125
+ }
126
+ export type NotificationType = "comment" | "reply" | "upvote" | "follow" | "moderation";
127
+ export interface Notification {
128
+ id: string;
129
+ type: NotificationType;
130
+ title: string;
131
+ body: string;
132
+ read: boolean;
133
+ /** For aggregated upvotes: "received N new upvotes". */
134
+ count: number;
135
+ actor: Author | null;
136
+ target: {
137
+ type: "post" | "comment" | "member";
138
+ id: string | null;
139
+ postId: string | null;
140
+ } | null;
141
+ createdAt: string;
142
+ }
143
+ export interface TrendingTag {
144
+ tag: string;
145
+ count: number;
146
+ }
147
+ export interface CommunityInfo {
148
+ slug: string;
149
+ name: string;
150
+ status: "active" | "read_only" | "disabled";
151
+ limits: {
152
+ postMaxChars: number;
153
+ commentMaxChars: number;
154
+ maxImagesPerPost: number;
155
+ maxLinksPerPost: number;
156
+ maxLinksPerComment: number;
157
+ };
158
+ }
159
+ export interface MediaItem {
160
+ id: string;
161
+ url: string;
162
+ mimeType: string;
163
+ size: number;
164
+ status: "uploading" | "pending" | "attached";
165
+ }
166
+ export interface UploadTicket {
167
+ method: "POST";
168
+ url: string;
169
+ fields: Record<string, string | number>;
170
+ fileField: string;
171
+ expiresAt: string;
172
+ }
173
+ export type ReportReason = "spam" | "harassment" | "hate" | "sexual" | "violence" | "misinformation" | "other";
174
+ export type ReportTarget = "post" | "comment" | "member" | "link";
175
+ export interface ReportGroup {
176
+ targetType: ReportTarget;
177
+ targetId: string;
178
+ count: number;
179
+ reasons: ReportReason[];
180
+ notes: string[];
181
+ firstReportedAt: string;
182
+ }
183
+ export interface LinkInfo {
184
+ code: string;
185
+ host: string;
186
+ verdict: "clean" | "suspicious" | "malicious" | "unchecked";
187
+ status: "active" | "inactive" | "blocked";
188
+ /** What a tap would do: open at once, show the warning page, or refuse. */
189
+ decision: "redirect" | "warn" | "blocked" | "gone" | "notfound";
190
+ }
191
+ /** A cursor page. Pass `nextCursor` back to get the next one. */
192
+ export interface Page<T> {
193
+ items: T[];
194
+ nextCursor: string | null;
195
+ hasMore: boolean;
196
+ }
197
+ export interface PageParams {
198
+ cursor?: string;
199
+ limit?: number;
200
+ }
201
+ /** A picked image on the device, before it is uploaded. */
202
+ export interface LocalImage {
203
+ uri: string;
204
+ mimeType: "image/jpeg" | "image/png" | "image/webp";
205
+ /** Bytes. Declared to the server up front so storage can be reserved. */
206
+ size: number;
207
+ name?: string;
208
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Shapes returned by the Community API. Dates are ISO strings so responses can be persisted as plain JSON. */
2
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { LocalImage, UploadTicket } from "./types.js";
2
+ /** Sends one file to the storage provider with a signed ticket. Replaceable (tests, other runtimes). */
3
+ export type FileUploader = (input: {
4
+ ticket: UploadTicket;
5
+ image: LocalImage;
6
+ /** Bytes sent so far and in total, for a progress bar. */
7
+ onProgress: (sent: number, total: number) => void;
8
+ signal?: AbortSignal;
9
+ }) => Promise<void>;
10
+ /**
11
+ * The default uploader. It uses XMLHttpRequest because that is the only way to get upload progress in React Native
12
+ * (`fetch` has none), and it appends the file as `{ uri, name, type }`, which React Native's FormData streams from disk.
13
+ */
14
+ export declare const xhrUploader: FileUploader;
package/dist/upload.js ADDED
@@ -0,0 +1,35 @@
1
+ /// <reference lib="dom" />
2
+ import { CommunityApiError } from "./errors.js";
3
+ /**
4
+ * The default uploader. It uses XMLHttpRequest because that is the only way to get upload progress in React Native
5
+ * (`fetch` has none), and it appends the file as `{ uri, name, type }`, which React Native's FormData streams from disk.
6
+ */
7
+ export const xhrUploader = ({ ticket, image, onProgress, signal }) => new Promise((resolve, reject) => {
8
+ const form = new FormData();
9
+ for (const [key, value] of Object.entries(ticket.fields))
10
+ form.append(key, String(value));
11
+ form.append(ticket.fileField, {
12
+ uri: image.uri,
13
+ name: image.name ?? "photo",
14
+ type: image.mimeType,
15
+ });
16
+ const xhr = new XMLHttpRequest();
17
+ xhr.open(ticket.method, ticket.url);
18
+ xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded, e.total);
19
+ xhr.onload = () => {
20
+ if (xhr.status >= 200 && xhr.status < 300)
21
+ return resolve();
22
+ reject(new CommunityApiError("The image could not be uploaded. Please try again.", xhr.status, "upload_failed"));
23
+ };
24
+ xhr.onerror = () => reject(new CommunityApiError("Could not reach the server. Check your connection.", 0, "network_error"));
25
+ xhr.ontimeout = () => reject(new CommunityApiError("The upload took too long. Please try again.", 0, "timeout"));
26
+ xhr.timeout = 120000;
27
+ const onAbort = () => {
28
+ xhr.abort();
29
+ reject(new CommunityApiError("Request cancelled", 0, "aborted"));
30
+ };
31
+ if (signal?.aborted)
32
+ return onAbort();
33
+ signal?.addEventListener("abort", onAbort, { once: true });
34
+ xhr.send(form);
35
+ });
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@pyqdeck/community-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Typed client for the PYQDeck Community API (posts, comments, members, short links). No Clerk, React or cache inside: the app supplies the token and owns caching.",
5
+ "keywords": [
6
+ "pyqdeck",
7
+ "community",
8
+ "sdk",
9
+ "react-native",
10
+ "expo",
11
+ "tanstack-query"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Raiyan Hasan",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/hasanraiyan/pyqdeck-community.git",
18
+ "directory": "sdk"
19
+ },
20
+ "homepage": "https://github.com/hasanraiyan/pyqdeck-community/tree/master/sdk#readme",
21
+ "bugs": "https://github.com/hasanraiyan/pyqdeck-community/issues",
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "prepublishOnly": "pnpm run build"
44
+ },
45
+ "devDependencies": {
46
+ "typescript": "~6.0.0"
47
+ }
48
+ }