@rocapine/community-core 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 +21 -0
- package/README.md +173 -0
- package/lib/config.d.ts +74 -0
- package/lib/config.js +51 -0
- package/lib/events.d.ts +20 -0
- package/lib/events.js +33 -0
- package/lib/hooks.d.ts +122 -0
- package/lib/hooks.js +494 -0
- package/lib/identity.d.ts +23 -0
- package/lib/identity.js +103 -0
- package/lib/inbox-hooks.d.ts +23 -0
- package/lib/inbox-hooks.js +57 -0
- package/lib/inbox-service.d.ts +26 -0
- package/lib/inbox-service.js +94 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +89 -0
- package/lib/models.d.ts +187 -0
- package/lib/models.js +178 -0
- package/lib/provider.d.ts +7 -0
- package/lib/provider.js +23 -0
- package/lib/schema-check.d.ts +19 -0
- package/lib/schema-check.js +52 -0
- package/lib/service.d.ts +147 -0
- package/lib/service.js +454 -0
- package/lib/time.d.ts +21 -0
- package/lib/time.js +32 -0
- package/package.json +32 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// React Query hooks over the inbox service. Ported from a host app's
|
|
3
|
+
// notification-center hooks, gated on `cfg.modules.inbox` in addition
|
|
4
|
+
// to the usual `cfg.supabase !== null` degraded-mode guard.
|
|
5
|
+
//
|
|
6
|
+
// No React Native / analytics-SDK / store imports here.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.useNotificationInbox = useNotificationInbox;
|
|
9
|
+
exports.useUnreadNotificationCount = useUnreadNotificationCount;
|
|
10
|
+
exports.useMarkInboxSeen = useMarkInboxSeen;
|
|
11
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
12
|
+
const provider_1 = require("./provider");
|
|
13
|
+
const inbox_service_1 = require("./inbox-service");
|
|
14
|
+
const INBOX_KEY = ["community", "inbox"];
|
|
15
|
+
/**
|
|
16
|
+
* The inbox, shared by the host's badge and the center screen — one query
|
|
17
|
+
* key backs both, so opening the screen never triggers a second fetch on top
|
|
18
|
+
* of the badge's. Refreshes on foreground like every query (host's own
|
|
19
|
+
* focusManager wiring, same as the rest of this package).
|
|
20
|
+
*/
|
|
21
|
+
function useNotificationInbox() {
|
|
22
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
23
|
+
return (0, react_query_1.useQuery)({
|
|
24
|
+
queryKey: INBOX_KEY,
|
|
25
|
+
queryFn: () => (0, inbox_service_1.fetchInbox)(cfg),
|
|
26
|
+
enabled: cfg.supabase !== null && cfg.modules.inbox,
|
|
27
|
+
staleTime: 60000,
|
|
28
|
+
retry: 1,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Unread badge count. Fail-soft rule (mold): the inbox is an enhancement, so a
|
|
33
|
+
* fetch error, a disabled module, or a degraded (no-backend) host must never
|
|
34
|
+
* surface upstream — this returns 0 in every one of those cases, never
|
|
35
|
+
* `undefined` and never a thrown error.
|
|
36
|
+
*/
|
|
37
|
+
function useUnreadNotificationCount() {
|
|
38
|
+
const { data } = useNotificationInbox();
|
|
39
|
+
return data ? (0, inbox_service_1.unreadCount)(data.items, data.seenAt) : 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Moves the seen marker past everything currently in the inbox so the badge
|
|
43
|
+
* clears immediately (optimistic), then persists the clock-skew-safe marker
|
|
44
|
+
* server-side (see `markInboxSeen`) — the next fetch reconciles the cache
|
|
45
|
+
* with that authoritative value.
|
|
46
|
+
*/
|
|
47
|
+
function useMarkInboxSeen() {
|
|
48
|
+
const cfg = (0, provider_1.useCommunityConfig)();
|
|
49
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
50
|
+
return (0, react_query_1.useMutation)({
|
|
51
|
+
mutationFn: () => (0, inbox_service_1.markInboxSeen)(cfg),
|
|
52
|
+
onMutate: () => {
|
|
53
|
+
const optimisticSeenAt = new Date().toISOString();
|
|
54
|
+
queryClient.setQueryData(INBOX_KEY, (state) => state ? { ...state, seenAt: optimisticSeenAt } : state);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ResolvedCommunityConfig } from "./config";
|
|
2
|
+
export type InboxItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: "like" | "comment" | "reaction" | "official_post" | (string & {});
|
|
5
|
+
createdAt: string;
|
|
6
|
+
actorName: string | null;
|
|
7
|
+
postId: string | null;
|
|
8
|
+
payload: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
export interface InboxState {
|
|
11
|
+
items: InboxItem[];
|
|
12
|
+
/** Everything at or before this instant is read; null = never opened. */
|
|
13
|
+
seenAt: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** The inbox's whole state in one fetch: items (newest first) + seen marker. */
|
|
16
|
+
export declare function fetchInbox(cfg: ResolvedCommunityConfig): Promise<InboxState>;
|
|
17
|
+
/**
|
|
18
|
+
* Marks everything currently in the inbox as read. The brief's signature
|
|
19
|
+
* takes only `cfg` (unlike the mold's `markNotificationsSeen(latestItemCreatedAt)`),
|
|
20
|
+
* so the clock-skew-safe anchor is derived internally: a cheap `list_notifications`
|
|
21
|
+
* call for just the newest row, rather than dropping the guard or duplicating
|
|
22
|
+
* the full `fetchInbox` (items + seen marker) round trip.
|
|
23
|
+
*/
|
|
24
|
+
export declare function markInboxSeen(cfg: ResolvedCommunityConfig): Promise<void>;
|
|
25
|
+
/** Unread = items strictly newer than the seen marker (all of them if never opened). Pure. */
|
|
26
|
+
export declare function unreadCount(items: InboxItem[], seenAt: string | null): number;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Notification inbox service — every function is a thin wrapper around the
|
|
3
|
+
// injected Supabase client, gated on `cfg.modules.inbox` (the mutation/read
|
|
4
|
+
// pair below throw when the module is off, same pattern as
|
|
5
|
+
// setReaction/votePoll in service.ts; hooks additionally gate via `enabled`
|
|
6
|
+
// so a disabled module never even reaches these).
|
|
7
|
+
//
|
|
8
|
+
// Ported from a host app's notification-center service. Transformations:
|
|
9
|
+
// kind `prayer` -> `reaction` (the mold's generic reaction module, same
|
|
10
|
+
// prayer->reaction rename as the rest of this package); the `kind` union is
|
|
11
|
+
// widened with `(string & {})` so a custom service-role kind (that host's
|
|
12
|
+
// support-reply kind) passes through untouched instead of being special-cased
|
|
13
|
+
// here — the host UI decides what to do with a kind it doesn't recognize.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.fetchInbox = fetchInbox;
|
|
16
|
+
exports.markInboxSeen = markInboxSeen;
|
|
17
|
+
exports.unreadCount = unreadCount;
|
|
18
|
+
const identity_1 = require("./identity");
|
|
19
|
+
const INBOX_PAGE_SIZE = 50;
|
|
20
|
+
async function requireUid(cfg) {
|
|
21
|
+
const uid = await (0, identity_1.ensureIdentity)(cfg);
|
|
22
|
+
if (!uid)
|
|
23
|
+
throw new Error("You appear to be offline. Please try again.");
|
|
24
|
+
return uid;
|
|
25
|
+
}
|
|
26
|
+
function mapKind(raw) {
|
|
27
|
+
return raw === "prayer" ? "reaction" : raw;
|
|
28
|
+
}
|
|
29
|
+
function mapNotificationRow(row) {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
kind: mapKind(row.kind),
|
|
33
|
+
createdAt: row.created_at,
|
|
34
|
+
actorName: row.actor_username,
|
|
35
|
+
postId: row.post_id,
|
|
36
|
+
payload: { actorId: row.actor_id, postExcerpt: row.post_excerpt },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Move the seen marker past everything just read. Item timestamps are server
|
|
41
|
+
* clocks and the device clock can lag them by minutes, so "now" alone would
|
|
42
|
+
* leave the newest items permanently unread — take the max of both (mold
|
|
43
|
+
* `seenMarkerFor`).
|
|
44
|
+
*/
|
|
45
|
+
function seenMarkerFor(latestItemCreatedAt) {
|
|
46
|
+
const latest = latestItemCreatedAt ? Date.parse(latestItemCreatedAt) : 0;
|
|
47
|
+
return new Date(Math.max(Date.now(), Number.isNaN(latest) ? 0 : latest)).toISOString();
|
|
48
|
+
}
|
|
49
|
+
/** The inbox's whole state in one fetch: items (newest first) + seen marker. */
|
|
50
|
+
async function fetchInbox(cfg) {
|
|
51
|
+
if (!cfg.modules.inbox)
|
|
52
|
+
throw new Error("inbox module is not enabled");
|
|
53
|
+
const client = cfg.requireClient();
|
|
54
|
+
const uid = await requireUid(cfg);
|
|
55
|
+
const [listRes, seenRes] = await Promise.all([
|
|
56
|
+
client.rpc("list_notifications", { p_limit: INBOX_PAGE_SIZE }),
|
|
57
|
+
client.from("notification_seen").select("seen_at").eq("user_id", uid).maybeSingle(),
|
|
58
|
+
]);
|
|
59
|
+
if (listRes.error)
|
|
60
|
+
throw listRes.error;
|
|
61
|
+
if (seenRes.error)
|
|
62
|
+
throw seenRes.error;
|
|
63
|
+
const items = (listRes.data ?? []).map(mapNotificationRow);
|
|
64
|
+
return { items, seenAt: seenRes.data?.seen_at ?? null };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Marks everything currently in the inbox as read. The brief's signature
|
|
68
|
+
* takes only `cfg` (unlike the mold's `markNotificationsSeen(latestItemCreatedAt)`),
|
|
69
|
+
* so the clock-skew-safe anchor is derived internally: a cheap `list_notifications`
|
|
70
|
+
* call for just the newest row, rather than dropping the guard or duplicating
|
|
71
|
+
* the full `fetchInbox` (items + seen marker) round trip.
|
|
72
|
+
*/
|
|
73
|
+
async function markInboxSeen(cfg) {
|
|
74
|
+
if (!cfg.modules.inbox)
|
|
75
|
+
throw new Error("inbox module is not enabled");
|
|
76
|
+
const client = cfg.requireClient();
|
|
77
|
+
const uid = await requireUid(cfg);
|
|
78
|
+
const { data, error: listError } = await client.rpc("list_notifications", { p_limit: 1 });
|
|
79
|
+
if (listError)
|
|
80
|
+
throw listError;
|
|
81
|
+
const latestCreatedAt = (data ?? [])[0]?.created_at ?? null;
|
|
82
|
+
const { error } = await client
|
|
83
|
+
.from("notification_seen")
|
|
84
|
+
.upsert({ user_id: uid, seen_at: seenMarkerFor(latestCreatedAt) });
|
|
85
|
+
if (error)
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
/** Unread = items strictly newer than the seen marker (all of them if never opened). Pure. */
|
|
89
|
+
function unreadCount(items, seenAt) {
|
|
90
|
+
if (!seenAt)
|
|
91
|
+
return items.length;
|
|
92
|
+
const seen = Date.parse(seenAt);
|
|
93
|
+
return items.filter((item) => Date.parse(item.createdAt) > seen).length;
|
|
94
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type { CommunityTopicDef, CommunityModules, CommunityHostAdapters, CommunityFeedConfig, CommunityConfig, ResolvedCommunityConfig, } from "./config";
|
|
2
|
+
export { CommunityDisabledError, resolveConfig } from "./config";
|
|
3
|
+
export { CommunityProvider, useCommunityConfig } from "./provider";
|
|
4
|
+
export type { PostRow, PollOptionRow, CommentRow, ProfileRow, PollOption, FeedPoll, PollData, ReactionData, FeedPost, ThreadComment, CommunityProfile, } from "./models";
|
|
5
|
+
export { FEED_PAGE_SIZE, POST_MAX_LENGTH, COMMENT_MAX_LENGTH, POLL_MIN_OPTIONS, POLL_MAX_OPTIONS, POLL_OPTION_MAX_LENGTH, EMPTY_POLL_DATA, EMPTY_REACTION_DATA, displayName, normalizeTopic, mapProfileRow, buildPoll, pollPercent, mapPostRow, applyPollVote, applyReaction, mapCommentRow, newPostsLabel, newestCreatedAt, } from "./models";
|
|
6
|
+
export type { TimeAgoParts } from "./time";
|
|
7
|
+
export { timeAgo, timeAgoParts } from "./time";
|
|
8
|
+
export { COMMUNITY_EVENTS, emitEvent } from "./events";
|
|
9
|
+
export type { CommunityEventName } from "./events";
|
|
10
|
+
export { ensureIdentity, resetIdentity, syncProfileFromHost } from "./identity";
|
|
11
|
+
export { buildFeedSelect, fetchFeedPage, countNewPosts, fetchProfile, fetchUserPosts, searchPosts, fetchThread, createPost, votePoll, createComment, moderateOne, setLike, setReaction, fetchReactionSummaries, reportContent, blockUser, deleteOwnPost, deleteOwnComment, updateProfile, uploadAvatar, } from "./service";
|
|
12
|
+
export type { ModerationVerdict, CreatePostInput, ReportReason, ReportInput, UpdateProfileInput, UpdateProfileResult, } from "./service";
|
|
13
|
+
export { useCommunityFeed, useSearchPosts, useNewPostsCount, useCommunityUnseenCount, useThread, useProfile, useUserPosts, useMyUid, useUpdateProfile, useCreatePost, useCreateComment, useToggleLike, useVotePoll, useReactToPost, useReport, useBlockUser, useDeleteContent, } from "./hooks";
|
|
14
|
+
export { fetchInbox, markInboxSeen, unreadCount } from "./inbox-service";
|
|
15
|
+
export type { InboxItem, InboxState } from "./inbox-service";
|
|
16
|
+
export { useNotificationInbox, useUnreadNotificationCount, useMarkInboxSeen } from "./inbox-hooks";
|
|
17
|
+
export { REQUIRED_SCHEMA_VERSION } from "./schema-check";
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadAvatar = exports.updateProfile = exports.deleteOwnComment = exports.deleteOwnPost = exports.blockUser = exports.reportContent = exports.fetchReactionSummaries = exports.setReaction = exports.setLike = exports.moderateOne = exports.createComment = exports.votePoll = exports.createPost = exports.fetchThread = exports.searchPosts = exports.fetchUserPosts = exports.fetchProfile = exports.countNewPosts = exports.fetchFeedPage = exports.buildFeedSelect = exports.syncProfileFromHost = exports.resetIdentity = exports.ensureIdentity = exports.emitEvent = exports.COMMUNITY_EVENTS = exports.timeAgoParts = exports.timeAgo = exports.newestCreatedAt = exports.newPostsLabel = exports.mapCommentRow = exports.applyReaction = exports.applyPollVote = exports.mapPostRow = exports.pollPercent = exports.buildPoll = exports.mapProfileRow = exports.normalizeTopic = exports.displayName = exports.EMPTY_REACTION_DATA = exports.EMPTY_POLL_DATA = exports.POLL_OPTION_MAX_LENGTH = exports.POLL_MAX_OPTIONS = exports.POLL_MIN_OPTIONS = exports.COMMENT_MAX_LENGTH = exports.POST_MAX_LENGTH = exports.FEED_PAGE_SIZE = exports.useCommunityConfig = exports.CommunityProvider = exports.resolveConfig = exports.CommunityDisabledError = void 0;
|
|
4
|
+
exports.REQUIRED_SCHEMA_VERSION = exports.useMarkInboxSeen = exports.useUnreadNotificationCount = exports.useNotificationInbox = exports.unreadCount = exports.markInboxSeen = exports.fetchInbox = exports.useDeleteContent = exports.useBlockUser = exports.useReport = exports.useReactToPost = exports.useVotePoll = exports.useToggleLike = exports.useCreateComment = exports.useCreatePost = exports.useUpdateProfile = exports.useMyUid = exports.useUserPosts = exports.useProfile = exports.useThread = exports.useCommunityUnseenCount = exports.useNewPostsCount = exports.useSearchPosts = exports.useCommunityFeed = void 0;
|
|
5
|
+
var config_1 = require("./config");
|
|
6
|
+
Object.defineProperty(exports, "CommunityDisabledError", { enumerable: true, get: function () { return config_1.CommunityDisabledError; } });
|
|
7
|
+
Object.defineProperty(exports, "resolveConfig", { enumerable: true, get: function () { return config_1.resolveConfig; } });
|
|
8
|
+
var provider_1 = require("./provider");
|
|
9
|
+
Object.defineProperty(exports, "CommunityProvider", { enumerable: true, get: function () { return provider_1.CommunityProvider; } });
|
|
10
|
+
Object.defineProperty(exports, "useCommunityConfig", { enumerable: true, get: function () { return provider_1.useCommunityConfig; } });
|
|
11
|
+
var models_1 = require("./models");
|
|
12
|
+
Object.defineProperty(exports, "FEED_PAGE_SIZE", { enumerable: true, get: function () { return models_1.FEED_PAGE_SIZE; } });
|
|
13
|
+
Object.defineProperty(exports, "POST_MAX_LENGTH", { enumerable: true, get: function () { return models_1.POST_MAX_LENGTH; } });
|
|
14
|
+
Object.defineProperty(exports, "COMMENT_MAX_LENGTH", { enumerable: true, get: function () { return models_1.COMMENT_MAX_LENGTH; } });
|
|
15
|
+
Object.defineProperty(exports, "POLL_MIN_OPTIONS", { enumerable: true, get: function () { return models_1.POLL_MIN_OPTIONS; } });
|
|
16
|
+
Object.defineProperty(exports, "POLL_MAX_OPTIONS", { enumerable: true, get: function () { return models_1.POLL_MAX_OPTIONS; } });
|
|
17
|
+
Object.defineProperty(exports, "POLL_OPTION_MAX_LENGTH", { enumerable: true, get: function () { return models_1.POLL_OPTION_MAX_LENGTH; } });
|
|
18
|
+
Object.defineProperty(exports, "EMPTY_POLL_DATA", { enumerable: true, get: function () { return models_1.EMPTY_POLL_DATA; } });
|
|
19
|
+
Object.defineProperty(exports, "EMPTY_REACTION_DATA", { enumerable: true, get: function () { return models_1.EMPTY_REACTION_DATA; } });
|
|
20
|
+
Object.defineProperty(exports, "displayName", { enumerable: true, get: function () { return models_1.displayName; } });
|
|
21
|
+
Object.defineProperty(exports, "normalizeTopic", { enumerable: true, get: function () { return models_1.normalizeTopic; } });
|
|
22
|
+
Object.defineProperty(exports, "mapProfileRow", { enumerable: true, get: function () { return models_1.mapProfileRow; } });
|
|
23
|
+
Object.defineProperty(exports, "buildPoll", { enumerable: true, get: function () { return models_1.buildPoll; } });
|
|
24
|
+
Object.defineProperty(exports, "pollPercent", { enumerable: true, get: function () { return models_1.pollPercent; } });
|
|
25
|
+
Object.defineProperty(exports, "mapPostRow", { enumerable: true, get: function () { return models_1.mapPostRow; } });
|
|
26
|
+
Object.defineProperty(exports, "applyPollVote", { enumerable: true, get: function () { return models_1.applyPollVote; } });
|
|
27
|
+
Object.defineProperty(exports, "applyReaction", { enumerable: true, get: function () { return models_1.applyReaction; } });
|
|
28
|
+
Object.defineProperty(exports, "mapCommentRow", { enumerable: true, get: function () { return models_1.mapCommentRow; } });
|
|
29
|
+
Object.defineProperty(exports, "newPostsLabel", { enumerable: true, get: function () { return models_1.newPostsLabel; } });
|
|
30
|
+
Object.defineProperty(exports, "newestCreatedAt", { enumerable: true, get: function () { return models_1.newestCreatedAt; } });
|
|
31
|
+
var time_1 = require("./time");
|
|
32
|
+
Object.defineProperty(exports, "timeAgo", { enumerable: true, get: function () { return time_1.timeAgo; } });
|
|
33
|
+
Object.defineProperty(exports, "timeAgoParts", { enumerable: true, get: function () { return time_1.timeAgoParts; } });
|
|
34
|
+
var events_1 = require("./events");
|
|
35
|
+
Object.defineProperty(exports, "COMMUNITY_EVENTS", { enumerable: true, get: function () { return events_1.COMMUNITY_EVENTS; } });
|
|
36
|
+
Object.defineProperty(exports, "emitEvent", { enumerable: true, get: function () { return events_1.emitEvent; } });
|
|
37
|
+
var identity_1 = require("./identity");
|
|
38
|
+
Object.defineProperty(exports, "ensureIdentity", { enumerable: true, get: function () { return identity_1.ensureIdentity; } });
|
|
39
|
+
Object.defineProperty(exports, "resetIdentity", { enumerable: true, get: function () { return identity_1.resetIdentity; } });
|
|
40
|
+
Object.defineProperty(exports, "syncProfileFromHost", { enumerable: true, get: function () { return identity_1.syncProfileFromHost; } });
|
|
41
|
+
var service_1 = require("./service");
|
|
42
|
+
Object.defineProperty(exports, "buildFeedSelect", { enumerable: true, get: function () { return service_1.buildFeedSelect; } });
|
|
43
|
+
Object.defineProperty(exports, "fetchFeedPage", { enumerable: true, get: function () { return service_1.fetchFeedPage; } });
|
|
44
|
+
Object.defineProperty(exports, "countNewPosts", { enumerable: true, get: function () { return service_1.countNewPosts; } });
|
|
45
|
+
Object.defineProperty(exports, "fetchProfile", { enumerable: true, get: function () { return service_1.fetchProfile; } });
|
|
46
|
+
Object.defineProperty(exports, "fetchUserPosts", { enumerable: true, get: function () { return service_1.fetchUserPosts; } });
|
|
47
|
+
Object.defineProperty(exports, "searchPosts", { enumerable: true, get: function () { return service_1.searchPosts; } });
|
|
48
|
+
Object.defineProperty(exports, "fetchThread", { enumerable: true, get: function () { return service_1.fetchThread; } });
|
|
49
|
+
Object.defineProperty(exports, "createPost", { enumerable: true, get: function () { return service_1.createPost; } });
|
|
50
|
+
Object.defineProperty(exports, "votePoll", { enumerable: true, get: function () { return service_1.votePoll; } });
|
|
51
|
+
Object.defineProperty(exports, "createComment", { enumerable: true, get: function () { return service_1.createComment; } });
|
|
52
|
+
Object.defineProperty(exports, "moderateOne", { enumerable: true, get: function () { return service_1.moderateOne; } });
|
|
53
|
+
Object.defineProperty(exports, "setLike", { enumerable: true, get: function () { return service_1.setLike; } });
|
|
54
|
+
Object.defineProperty(exports, "setReaction", { enumerable: true, get: function () { return service_1.setReaction; } });
|
|
55
|
+
Object.defineProperty(exports, "fetchReactionSummaries", { enumerable: true, get: function () { return service_1.fetchReactionSummaries; } });
|
|
56
|
+
Object.defineProperty(exports, "reportContent", { enumerable: true, get: function () { return service_1.reportContent; } });
|
|
57
|
+
Object.defineProperty(exports, "blockUser", { enumerable: true, get: function () { return service_1.blockUser; } });
|
|
58
|
+
Object.defineProperty(exports, "deleteOwnPost", { enumerable: true, get: function () { return service_1.deleteOwnPost; } });
|
|
59
|
+
Object.defineProperty(exports, "deleteOwnComment", { enumerable: true, get: function () { return service_1.deleteOwnComment; } });
|
|
60
|
+
Object.defineProperty(exports, "updateProfile", { enumerable: true, get: function () { return service_1.updateProfile; } });
|
|
61
|
+
Object.defineProperty(exports, "uploadAvatar", { enumerable: true, get: function () { return service_1.uploadAvatar; } });
|
|
62
|
+
var hooks_1 = require("./hooks");
|
|
63
|
+
Object.defineProperty(exports, "useCommunityFeed", { enumerable: true, get: function () { return hooks_1.useCommunityFeed; } });
|
|
64
|
+
Object.defineProperty(exports, "useSearchPosts", { enumerable: true, get: function () { return hooks_1.useSearchPosts; } });
|
|
65
|
+
Object.defineProperty(exports, "useNewPostsCount", { enumerable: true, get: function () { return hooks_1.useNewPostsCount; } });
|
|
66
|
+
Object.defineProperty(exports, "useCommunityUnseenCount", { enumerable: true, get: function () { return hooks_1.useCommunityUnseenCount; } });
|
|
67
|
+
Object.defineProperty(exports, "useThread", { enumerable: true, get: function () { return hooks_1.useThread; } });
|
|
68
|
+
Object.defineProperty(exports, "useProfile", { enumerable: true, get: function () { return hooks_1.useProfile; } });
|
|
69
|
+
Object.defineProperty(exports, "useUserPosts", { enumerable: true, get: function () { return hooks_1.useUserPosts; } });
|
|
70
|
+
Object.defineProperty(exports, "useMyUid", { enumerable: true, get: function () { return hooks_1.useMyUid; } });
|
|
71
|
+
Object.defineProperty(exports, "useUpdateProfile", { enumerable: true, get: function () { return hooks_1.useUpdateProfile; } });
|
|
72
|
+
Object.defineProperty(exports, "useCreatePost", { enumerable: true, get: function () { return hooks_1.useCreatePost; } });
|
|
73
|
+
Object.defineProperty(exports, "useCreateComment", { enumerable: true, get: function () { return hooks_1.useCreateComment; } });
|
|
74
|
+
Object.defineProperty(exports, "useToggleLike", { enumerable: true, get: function () { return hooks_1.useToggleLike; } });
|
|
75
|
+
Object.defineProperty(exports, "useVotePoll", { enumerable: true, get: function () { return hooks_1.useVotePoll; } });
|
|
76
|
+
Object.defineProperty(exports, "useReactToPost", { enumerable: true, get: function () { return hooks_1.useReactToPost; } });
|
|
77
|
+
Object.defineProperty(exports, "useReport", { enumerable: true, get: function () { return hooks_1.useReport; } });
|
|
78
|
+
Object.defineProperty(exports, "useBlockUser", { enumerable: true, get: function () { return hooks_1.useBlockUser; } });
|
|
79
|
+
Object.defineProperty(exports, "useDeleteContent", { enumerable: true, get: function () { return hooks_1.useDeleteContent; } });
|
|
80
|
+
var inbox_service_1 = require("./inbox-service");
|
|
81
|
+
Object.defineProperty(exports, "fetchInbox", { enumerable: true, get: function () { return inbox_service_1.fetchInbox; } });
|
|
82
|
+
Object.defineProperty(exports, "markInboxSeen", { enumerable: true, get: function () { return inbox_service_1.markInboxSeen; } });
|
|
83
|
+
Object.defineProperty(exports, "unreadCount", { enumerable: true, get: function () { return inbox_service_1.unreadCount; } });
|
|
84
|
+
var inbox_hooks_1 = require("./inbox-hooks");
|
|
85
|
+
Object.defineProperty(exports, "useNotificationInbox", { enumerable: true, get: function () { return inbox_hooks_1.useNotificationInbox; } });
|
|
86
|
+
Object.defineProperty(exports, "useUnreadNotificationCount", { enumerable: true, get: function () { return inbox_hooks_1.useUnreadNotificationCount; } });
|
|
87
|
+
Object.defineProperty(exports, "useMarkInboxSeen", { enumerable: true, get: function () { return inbox_hooks_1.useMarkInboxSeen; } });
|
|
88
|
+
var schema_check_1 = require("./schema-check");
|
|
89
|
+
Object.defineProperty(exports, "REQUIRED_SCHEMA_VERSION", { enumerable: true, get: function () { return schema_check_1.REQUIRED_SCHEMA_VERSION; } });
|
package/lib/models.d.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { CommunityTopicDef } from "./config";
|
|
2
|
+
export declare const FEED_PAGE_SIZE = 20;
|
|
3
|
+
export declare const POST_MAX_LENGTH = 2000;
|
|
4
|
+
export declare const COMMENT_MAX_LENGTH = 1000;
|
|
5
|
+
export declare const POLL_MIN_OPTIONS = 2;
|
|
6
|
+
export declare const POLL_MAX_OPTIONS = 4;
|
|
7
|
+
export declare const POLL_OPTION_MAX_LENGTH = 60;
|
|
8
|
+
/** Raw shape returned by the feed select (see the services layer). */
|
|
9
|
+
export interface PostRow {
|
|
10
|
+
id: string;
|
|
11
|
+
author_id: string;
|
|
12
|
+
topic: string | null;
|
|
13
|
+
content: string;
|
|
14
|
+
status: "visible" | "hidden" | "deleted";
|
|
15
|
+
pinned_at: string | null;
|
|
16
|
+
created_at: string;
|
|
17
|
+
profiles: {
|
|
18
|
+
username: string | null;
|
|
19
|
+
is_official: boolean | null;
|
|
20
|
+
handle: string | null;
|
|
21
|
+
avatar_url: string | null;
|
|
22
|
+
} | null;
|
|
23
|
+
likes: {
|
|
24
|
+
count: number;
|
|
25
|
+
}[];
|
|
26
|
+
comments: {
|
|
27
|
+
count: number;
|
|
28
|
+
}[];
|
|
29
|
+
/** Empty for a post without a poll. */
|
|
30
|
+
poll_options: PollOptionRow[];
|
|
31
|
+
}
|
|
32
|
+
export interface PollOptionRow {
|
|
33
|
+
id: string;
|
|
34
|
+
idx: number;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
export interface CommentRow {
|
|
38
|
+
id: string;
|
|
39
|
+
post_id: string;
|
|
40
|
+
author_id: string;
|
|
41
|
+
content: string;
|
|
42
|
+
status: "visible" | "hidden" | "deleted";
|
|
43
|
+
created_at: string;
|
|
44
|
+
profiles: {
|
|
45
|
+
username: string | null;
|
|
46
|
+
is_official: boolean | null;
|
|
47
|
+
handle: string | null;
|
|
48
|
+
avatar_url: string | null;
|
|
49
|
+
} | null;
|
|
50
|
+
}
|
|
51
|
+
export interface PollOption {
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
votes: number;
|
|
55
|
+
}
|
|
56
|
+
export interface FeedPoll {
|
|
57
|
+
/** In creation order (idx). */
|
|
58
|
+
options: PollOption[];
|
|
59
|
+
/** Option id I voted for; null until I vote. */
|
|
60
|
+
myOptionId: string | null;
|
|
61
|
+
totalVotes: number;
|
|
62
|
+
}
|
|
63
|
+
/** Batched per-post poll state fetched alongside a feed page:
|
|
64
|
+
* counts is option_id -> votes, myVotes is post_id -> my option_id. */
|
|
65
|
+
export interface PollData {
|
|
66
|
+
counts: ReadonlyMap<string, number>;
|
|
67
|
+
myVotes: ReadonlyMap<string, string>;
|
|
68
|
+
}
|
|
69
|
+
export declare const EMPTY_POLL_DATA: PollData;
|
|
70
|
+
/** Batched per-post reaction state fetched alongside a feed page (generalized from
|
|
71
|
+
* a host app's prayer-style feature: prayerCount/hasPrayed -> reactionCount/hasReacted).
|
|
72
|
+
* `lastReactorName` mirrors the backend's security-definer summary RPC, which returns
|
|
73
|
+
* each post's most recent reactor's display name alongside the count. */
|
|
74
|
+
export interface ReactionData {
|
|
75
|
+
counts: ReadonlyMap<string, number>;
|
|
76
|
+
mine: ReadonlySet<string>;
|
|
77
|
+
lastReactorName: ReadonlyMap<string, string | null>;
|
|
78
|
+
}
|
|
79
|
+
export declare const EMPTY_REACTION_DATA: ReactionData;
|
|
80
|
+
export interface FeedPost {
|
|
81
|
+
id: string;
|
|
82
|
+
authorId: string;
|
|
83
|
+
authorName: string;
|
|
84
|
+
/** Official house account — renders a verified seal. */
|
|
85
|
+
authorOfficial: boolean;
|
|
86
|
+
/** Unique public handle, e.g. "marie-4821". Null on stale caches. */
|
|
87
|
+
authorHandle: string | null;
|
|
88
|
+
authorAvatarUrl: string | null;
|
|
89
|
+
/** App-defined free text; unknown values stay visible under their raw id (see normalizeTopic). */
|
|
90
|
+
topic: string | null;
|
|
91
|
+
text: string;
|
|
92
|
+
/** 'deleted' rows are excluded at the query level, so never present here. */
|
|
93
|
+
status: "visible" | "hidden";
|
|
94
|
+
likeCount: number;
|
|
95
|
+
commentCount: number;
|
|
96
|
+
likedByMe: boolean;
|
|
97
|
+
isOwn: boolean;
|
|
98
|
+
createdAt: string;
|
|
99
|
+
/** Set when the dashboard pinned this post; pinned posts sort first. */
|
|
100
|
+
pinnedAt: string | null;
|
|
101
|
+
/** Present when the post carries a poll. */
|
|
102
|
+
poll: FeedPoll | null;
|
|
103
|
+
/** Generic "reaction" module count (e.g. a "pray for" tap); the label/verb is host-defined. */
|
|
104
|
+
reactionCount: number;
|
|
105
|
+
hasReacted: boolean;
|
|
106
|
+
/** Display name of the most recent reactor, for a "X and N others reacted" affordance;
|
|
107
|
+
* null when nobody has reacted yet or the reaction module is off. */
|
|
108
|
+
lastReactorName: string | null;
|
|
109
|
+
}
|
|
110
|
+
export interface ThreadComment {
|
|
111
|
+
id: string;
|
|
112
|
+
postId: string;
|
|
113
|
+
authorId: string;
|
|
114
|
+
authorName: string;
|
|
115
|
+
authorOfficial: boolean;
|
|
116
|
+
authorHandle: string | null;
|
|
117
|
+
authorAvatarUrl: string | null;
|
|
118
|
+
text: string;
|
|
119
|
+
isOwn: boolean;
|
|
120
|
+
createdAt: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Public display name, with a host-configured fallback for profiles not yet named.
|
|
124
|
+
* Accepts either a raw username or any profile-shaped object carrying one (a row's
|
|
125
|
+
* `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`).
|
|
126
|
+
*/
|
|
127
|
+
export declare function displayName(profile: string | null | undefined | {
|
|
128
|
+
username?: string | null;
|
|
129
|
+
}, fallback: string): string;
|
|
130
|
+
/**
|
|
131
|
+
* The topic column is app-defined free text. Unlike the mold (which dropped topics
|
|
132
|
+
* unknown to the app's static list), unknown topics stay visible under their raw id —
|
|
133
|
+
* the topics list is now host-config-driven and may lag behind live data.
|
|
134
|
+
*/
|
|
135
|
+
export declare function normalizeTopic(raw: string | null, topics: readonly CommunityTopicDef[]): string | null;
|
|
136
|
+
export interface ProfileRow {
|
|
137
|
+
id: string;
|
|
138
|
+
username: string | null;
|
|
139
|
+
handle: string | null;
|
|
140
|
+
is_official: boolean | null;
|
|
141
|
+
bio: string | null;
|
|
142
|
+
avatar_url: string | null;
|
|
143
|
+
}
|
|
144
|
+
export interface CommunityProfile {
|
|
145
|
+
id: string;
|
|
146
|
+
name: string;
|
|
147
|
+
handle: string | null;
|
|
148
|
+
official: boolean;
|
|
149
|
+
bio: string | null;
|
|
150
|
+
avatarUrl: string | null;
|
|
151
|
+
/** Raw username, when available (pre-fallback, unlike `name`); populated by
|
|
152
|
+
* mapProfileRow so a CommunityProfile can itself be threaded back into displayName(). */
|
|
153
|
+
username?: string | null;
|
|
154
|
+
}
|
|
155
|
+
export declare function mapProfileRow(row: ProfileRow, fallback: string): CommunityProfile;
|
|
156
|
+
/** Assemble a FeedPoll from the embedded option rows + batched vote data. */
|
|
157
|
+
export declare function buildPoll(options: PollOptionRow[], counts: ReadonlyMap<string, number>, myOptionId: string | null): FeedPoll | null;
|
|
158
|
+
/** Integer percentage for a result bar; a zero-vote poll shows 0% everywhere. */
|
|
159
|
+
export declare function pollPercent(votes: number, totalVotes: number): number;
|
|
160
|
+
export declare function mapPostRow(row: PostRow, myUid: string, fallback: string, topics: readonly CommunityTopicDef[], likedPostIds: ReadonlySet<string>, pollData?: PollData, reactionData?: ReactionData,
|
|
161
|
+
/** Host extension point (`CommunityConfig.feed.transformPost`), run last with
|
|
162
|
+
* the raw row (including any `extraPostColumns`) this post was built from. */
|
|
163
|
+
transformPost?: (post: FeedPost, row: Record<string, unknown>) => FeedPost): FeedPost;
|
|
164
|
+
/**
|
|
165
|
+
* Optimistic vote application: moves my vote to `optionId` on the matching
|
|
166
|
+
* post, shifting per-option counts; the total only grows on a first vote.
|
|
167
|
+
*/
|
|
168
|
+
export declare function applyPollVote(post: FeedPost, postId: string, optionId: string): FeedPost;
|
|
169
|
+
/**
|
|
170
|
+
* Optimistic reaction application: +1 and hasReacted on the given post
|
|
171
|
+
* (idempotent: re-applying to an already-reacted post is a no-op that returns
|
|
172
|
+
* the same object back). Generalized from a host app's applyPrayer — the
|
|
173
|
+
* caller is expected to have already located the target post (e.g. via
|
|
174
|
+
* `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
|
|
175
|
+
*/
|
|
176
|
+
export declare function applyReaction(post: FeedPost): FeedPost;
|
|
177
|
+
export declare function mapCommentRow(row: CommentRow, myUid: string, fallback: string): ThreadComment;
|
|
178
|
+
/** Floating pill label. "1 new post" / "N new posts". */
|
|
179
|
+
export declare function newPostsLabel(count: number): string;
|
|
180
|
+
/**
|
|
181
|
+
* Max createdAt across loaded posts. posts[0] is no longer the newest once a
|
|
182
|
+
* pinned post floats to the top, and the new-posts pill must not count the
|
|
183
|
+
* already-visible pinned post as "new".
|
|
184
|
+
*/
|
|
185
|
+
export declare function newestCreatedAt(posts: readonly {
|
|
186
|
+
createdAt: string;
|
|
187
|
+
}[]): string | null;
|
package/lib/models.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure mapping between community backend rows and UI models.
|
|
3
|
+
// No React Native / Supabase imports: this module is exercised by vitest in a node env.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.EMPTY_REACTION_DATA = exports.EMPTY_POLL_DATA = exports.POLL_OPTION_MAX_LENGTH = exports.POLL_MAX_OPTIONS = exports.POLL_MIN_OPTIONS = exports.COMMENT_MAX_LENGTH = exports.POST_MAX_LENGTH = exports.FEED_PAGE_SIZE = void 0;
|
|
6
|
+
exports.displayName = displayName;
|
|
7
|
+
exports.normalizeTopic = normalizeTopic;
|
|
8
|
+
exports.mapProfileRow = mapProfileRow;
|
|
9
|
+
exports.buildPoll = buildPoll;
|
|
10
|
+
exports.pollPercent = pollPercent;
|
|
11
|
+
exports.mapPostRow = mapPostRow;
|
|
12
|
+
exports.applyPollVote = applyPollVote;
|
|
13
|
+
exports.applyReaction = applyReaction;
|
|
14
|
+
exports.mapCommentRow = mapCommentRow;
|
|
15
|
+
exports.newPostsLabel = newPostsLabel;
|
|
16
|
+
exports.newestCreatedAt = newestCreatedAt;
|
|
17
|
+
exports.FEED_PAGE_SIZE = 20;
|
|
18
|
+
// ============ DB-MIRRORING LIMITS (DO NOT CHANGE WITHOUT CHECKING THE DB) ============
|
|
19
|
+
// Client-side mirrors of the community backend's check constraints, so the cap is an
|
|
20
|
+
// invisible input limit instead of a generic insert error. Verified against the mold's
|
|
21
|
+
// own constants on 2026-08-31 — POST_MAX_LENGTH and COMMENT_MAX_LENGTH differ from the
|
|
22
|
+
// task brief's placeholder values (600 / 300); the mold/DB values below win.
|
|
23
|
+
exports.POST_MAX_LENGTH = 2000;
|
|
24
|
+
exports.COMMENT_MAX_LENGTH = 1000;
|
|
25
|
+
exports.POLL_MIN_OPTIONS = 2;
|
|
26
|
+
exports.POLL_MAX_OPTIONS = 4;
|
|
27
|
+
exports.POLL_OPTION_MAX_LENGTH = 60;
|
|
28
|
+
exports.EMPTY_POLL_DATA = { counts: new Map(), myVotes: new Map() };
|
|
29
|
+
exports.EMPTY_REACTION_DATA = {
|
|
30
|
+
counts: new Map(),
|
|
31
|
+
mine: new Set(),
|
|
32
|
+
lastReactorName: new Map(),
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Public display name, with a host-configured fallback for profiles not yet named.
|
|
36
|
+
* Accepts either a raw username or any profile-shaped object carrying one (a row's
|
|
37
|
+
* `.profiles`, a `ProfileRow`, or a previously-mapped `CommunityProfile`).
|
|
38
|
+
*/
|
|
39
|
+
function displayName(profile, fallback) {
|
|
40
|
+
const raw = typeof profile === "string" || profile == null ? profile : profile.username;
|
|
41
|
+
const trimmed = raw?.trim();
|
|
42
|
+
return trimmed ? trimmed : fallback;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The topic column is app-defined free text. Unlike the mold (which dropped topics
|
|
46
|
+
* unknown to the app's static list), unknown topics stay visible under their raw id —
|
|
47
|
+
* the topics list is now host-config-driven and may lag behind live data.
|
|
48
|
+
*/
|
|
49
|
+
function normalizeTopic(raw, topics) {
|
|
50
|
+
if (!raw)
|
|
51
|
+
return null;
|
|
52
|
+
const known = topics.find((t) => t.id === raw);
|
|
53
|
+
return known ? known.id : raw;
|
|
54
|
+
}
|
|
55
|
+
function mapProfileRow(row, fallback) {
|
|
56
|
+
return {
|
|
57
|
+
id: row.id,
|
|
58
|
+
name: displayName(row, fallback),
|
|
59
|
+
handle: row.handle ?? null,
|
|
60
|
+
official: row.is_official ?? false,
|
|
61
|
+
bio: row.bio ?? null,
|
|
62
|
+
avatarUrl: row.avatar_url ?? null,
|
|
63
|
+
// Raw username (pre-fallback), so a mapped CommunityProfile can itself be threaded back
|
|
64
|
+
// into displayName() elsewhere — see the field doc on CommunityProfile.
|
|
65
|
+
username: row.username ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Assemble a FeedPoll from the embedded option rows + batched vote data. */
|
|
69
|
+
function buildPoll(options, counts, myOptionId) {
|
|
70
|
+
if (options.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
const mapped = [...options]
|
|
73
|
+
.sort((a, b) => a.idx - b.idx)
|
|
74
|
+
.map((o) => ({ id: o.id, label: o.label, votes: counts.get(o.id) ?? 0 }));
|
|
75
|
+
return {
|
|
76
|
+
options: mapped,
|
|
77
|
+
myOptionId,
|
|
78
|
+
totalVotes: mapped.reduce((sum, o) => sum + o.votes, 0),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Integer percentage for a result bar; a zero-vote poll shows 0% everywhere. */
|
|
82
|
+
function pollPercent(votes, totalVotes) {
|
|
83
|
+
if (totalVotes <= 0)
|
|
84
|
+
return 0;
|
|
85
|
+
return Math.round((votes / totalVotes) * 100);
|
|
86
|
+
}
|
|
87
|
+
function mapPostRow(row, myUid, fallback, topics, likedPostIds, pollData = exports.EMPTY_POLL_DATA, reactionData = exports.EMPTY_REACTION_DATA,
|
|
88
|
+
/** Host extension point (`CommunityConfig.feed.transformPost`), run last with
|
|
89
|
+
* the raw row (including any `extraPostColumns`) this post was built from. */
|
|
90
|
+
transformPost) {
|
|
91
|
+
const post = {
|
|
92
|
+
id: row.id,
|
|
93
|
+
authorId: row.author_id,
|
|
94
|
+
authorName: displayName(row.profiles, fallback),
|
|
95
|
+
authorOfficial: row.profiles?.is_official ?? false,
|
|
96
|
+
authorHandle: row.profiles?.handle ?? null,
|
|
97
|
+
authorAvatarUrl: row.profiles?.avatar_url ?? null,
|
|
98
|
+
topic: normalizeTopic(row.topic, topics),
|
|
99
|
+
text: row.content,
|
|
100
|
+
status: row.status === "hidden" ? "hidden" : "visible",
|
|
101
|
+
likeCount: row.likes[0]?.count ?? 0,
|
|
102
|
+
commentCount: row.comments[0]?.count ?? 0,
|
|
103
|
+
likedByMe: likedPostIds.has(row.id),
|
|
104
|
+
isOwn: row.author_id === myUid,
|
|
105
|
+
createdAt: row.created_at,
|
|
106
|
+
pinnedAt: row.pinned_at,
|
|
107
|
+
poll: buildPoll(row.poll_options, pollData.counts, pollData.myVotes.get(row.id) ?? null),
|
|
108
|
+
reactionCount: reactionData.counts.get(row.id) ?? 0,
|
|
109
|
+
hasReacted: reactionData.mine.has(row.id),
|
|
110
|
+
lastReactorName: reactionData.lastReactorName.get(row.id) ?? null,
|
|
111
|
+
};
|
|
112
|
+
return transformPost ? transformPost(post, row) : post;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Optimistic vote application: moves my vote to `optionId` on the matching
|
|
116
|
+
* post, shifting per-option counts; the total only grows on a first vote.
|
|
117
|
+
*/
|
|
118
|
+
function applyPollVote(post, postId, optionId) {
|
|
119
|
+
if (post.id !== postId || !post.poll)
|
|
120
|
+
return post;
|
|
121
|
+
const prev = post.poll.myOptionId;
|
|
122
|
+
if (prev === optionId)
|
|
123
|
+
return post;
|
|
124
|
+
return {
|
|
125
|
+
...post,
|
|
126
|
+
poll: {
|
|
127
|
+
options: post.poll.options.map((o) => ({
|
|
128
|
+
...o,
|
|
129
|
+
votes: o.votes + (o.id === optionId ? 1 : 0) - (o.id === prev ? 1 : 0),
|
|
130
|
+
})),
|
|
131
|
+
myOptionId: optionId,
|
|
132
|
+
totalVotes: post.poll.totalVotes + (prev === null ? 1 : 0),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Optimistic reaction application: +1 and hasReacted on the given post
|
|
138
|
+
* (idempotent: re-applying to an already-reacted post is a no-op that returns
|
|
139
|
+
* the same object back). Generalized from a host app's applyPrayer — the
|
|
140
|
+
* caller is expected to have already located the target post (e.g. via
|
|
141
|
+
* `posts.map((p) => (p.id === postId ? applyReaction(p) : p))`).
|
|
142
|
+
*/
|
|
143
|
+
function applyReaction(post) {
|
|
144
|
+
if (post.hasReacted)
|
|
145
|
+
return post;
|
|
146
|
+
return { ...post, hasReacted: true, reactionCount: post.reactionCount + 1 };
|
|
147
|
+
}
|
|
148
|
+
function mapCommentRow(row, myUid, fallback) {
|
|
149
|
+
return {
|
|
150
|
+
id: row.id,
|
|
151
|
+
postId: row.post_id,
|
|
152
|
+
authorId: row.author_id,
|
|
153
|
+
authorName: displayName(row.profiles, fallback),
|
|
154
|
+
authorOfficial: row.profiles?.is_official ?? false,
|
|
155
|
+
authorHandle: row.profiles?.handle ?? null,
|
|
156
|
+
authorAvatarUrl: row.profiles?.avatar_url ?? null,
|
|
157
|
+
text: row.content,
|
|
158
|
+
isOwn: row.author_id === myUid,
|
|
159
|
+
createdAt: row.created_at,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Floating pill label. "1 new post" / "N new posts". */
|
|
163
|
+
function newPostsLabel(count) {
|
|
164
|
+
return count === 1 ? "1 new post" : `${count} new posts`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Max createdAt across loaded posts. posts[0] is no longer the newest once a
|
|
168
|
+
* pinned post floats to the top, and the new-posts pill must not count the
|
|
169
|
+
* already-visible pinned post as "new".
|
|
170
|
+
*/
|
|
171
|
+
function newestCreatedAt(posts) {
|
|
172
|
+
let newest = null;
|
|
173
|
+
for (const p of posts) {
|
|
174
|
+
if (newest === null || Date.parse(p.createdAt) > Date.parse(newest))
|
|
175
|
+
newest = p.createdAt;
|
|
176
|
+
}
|
|
177
|
+
return newest;
|
|
178
|
+
}
|