diff --git a/docs/assets/analytics/engagement-analytics.png b/docs/assets/analytics/engagement-analytics.png new file mode 100644 index 00000000..720619fe Binary files /dev/null and b/docs/assets/analytics/engagement-analytics.png differ diff --git a/docs/assets/analytics/journeys-1.png b/docs/assets/analytics/journeys-1.png new file mode 100644 index 00000000..1eadcdd8 Binary files /dev/null and b/docs/assets/analytics/journeys-1.png differ diff --git a/docs/assets/analytics/journeys-2.png b/docs/assets/analytics/journeys-2.png new file mode 100644 index 00000000..d100910a Binary files /dev/null and b/docs/assets/analytics/journeys-2.png differ diff --git a/docs/assets/analytics/journeys-3.png b/docs/assets/analytics/journeys-3.png new file mode 100644 index 00000000..4846469d Binary files /dev/null and b/docs/assets/analytics/journeys-3.png differ diff --git a/docs/assets/analytics/journeys-4.png b/docs/assets/analytics/journeys-4.png new file mode 100644 index 00000000..08d0098a Binary files /dev/null and b/docs/assets/analytics/journeys-4.png differ diff --git a/docs/assets/analytics/login-screen.png b/docs/assets/analytics/login-screen.png new file mode 100644 index 00000000..80fe24bb Binary files /dev/null and b/docs/assets/analytics/login-screen.png differ diff --git a/docs/assets/analytics/syllo-logo.png b/docs/assets/analytics/syllo-logo.png new file mode 100644 index 00000000..72d1bdbb Binary files /dev/null and b/docs/assets/analytics/syllo-logo.png differ diff --git a/docs/assets/analytics/syllo-play-2.png b/docs/assets/analytics/syllo-play-2.png new file mode 100644 index 00000000..07a37a93 Binary files /dev/null and b/docs/assets/analytics/syllo-play-2.png differ diff --git a/docs/assets/analytics/syllo-sharesheet.png b/docs/assets/analytics/syllo-sharesheet.png new file mode 100644 index 00000000..3f59f903 Binary files /dev/null and b/docs/assets/analytics/syllo-sharesheet.png differ diff --git a/docs/assets/notifications/component-examples.png b/docs/assets/notifications/component-examples.png new file mode 100644 index 00000000..33b8dd79 Binary files /dev/null and b/docs/assets/notifications/component-examples.png differ diff --git a/docs/assets/notifications/layer-urgency.png b/docs/assets/notifications/layer-urgency.png new file mode 100644 index 00000000..0046a348 Binary files /dev/null and b/docs/assets/notifications/layer-urgency.png differ diff --git a/docs/assets/notifications/syllo-pn-examples.png b/docs/assets/notifications/syllo-pn-examples.png new file mode 100644 index 00000000..9b0a0c8d Binary files /dev/null and b/docs/assets/notifications/syllo-pn-examples.png differ diff --git a/docs/assets/notifications/tangible-benefits.png b/docs/assets/notifications/tangible-benefits.png new file mode 100644 index 00000000..cff319bd Binary files /dev/null and b/docs/assets/notifications/tangible-benefits.png differ diff --git a/docs/assets/notifications/visible-progress.png b/docs/assets/notifications/visible-progress.png new file mode 100644 index 00000000..c721c9e3 Binary files /dev/null and b/docs/assets/notifications/visible-progress.png differ diff --git a/docs/capabilities/analytics/analytics-overview.md b/docs/capabilities/analytics/analytics-overview.md new file mode 100644 index 00000000..c9252fae --- /dev/null +++ b/docs/capabilities/analytics/analytics-overview.md @@ -0,0 +1,18 @@ +# Overview + +Devvit Journeys is an experimental telemetry feature that captures the full lifecycle of a gameplay session. It provides visibility into how players enter, progress through, abandon, and complete experiences within your game. + +This data can help identify friction points, analyze player behavior, inform product decisions, and surface opportunities to improve onboarding, progression, and overall engagement. + +## Beta requirements + +Devvit Journeys is a **gated beta**, which means that you’ll need to apply to unlock the ability to use it in your app. Current eligibility is aimed at established, already-engaged games rather than brand-new launches, and push notification partners are selected from active games with traction and predictable cadence. Check out our [featured games](https://www.reddit.com/r/GamesOnReddit/comments/1rydlny/games_launchpad/) to get an idea of what we’re looking for. + +## How to apply + +If you meet the beta requirements, fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLScB3eXHVCBf3kyHueyf3G_raxH9_BsCGiXyGjQOOmPxWz6fEg/viewform?usp=publish-editor) for consideration, and be sure to include: + +- The app identifier. +- Your push notification copy. We’ll do a quick review to ensure that it complies with our [Reddit Rules](https://redditinc.com/policies/reddit-rules). + +Note that spaces are limited, and not all apps that meet the criteria will be accepted. diff --git a/docs/capabilities/analytics/devvit-journeys.md b/docs/capabilities/analytics/devvit-journeys.md new file mode 100644 index 00000000..08399211 --- /dev/null +++ b/docs/capabilities/analytics/devvit-journeys.md @@ -0,0 +1,233 @@ +# Devvit Journeys + +Devvit Journeys adds a telemetry stream to your app that tracks the entire lifecycle of a user session. With journeys, you can: + +- Boost your game’s visibility and reach through richer gameplay insights. +- Make design and feature decisions based on real user data. +- Identify friction points and optimize for better user retention. + +A journey has a defined start and end point. Progress is tracked throughout and ends with a completion status. You can also attach optional game-specific data (like win/loss results or scores) at the end. + +:::note +This is currently an experimental feature, and you'll need to [apply](https://docs.google.com/forms/d/e/1FAIpQLScB3eXHVCBf3kyHueyf3G_raxH9_BsCGiXyGjQOOmPxWz6fEg/viewform?usp=publish-editor) for a spot in our beta program to implement Devvit Journeys. +::: + +## Journey map + +A journey map is a structured, instrumented flow that tracks a player’s progression through a specific experience in your game. Think of your journey map as a series of checkpoints (events) in your game, with: + +- A clear **start condition** (e.g., game begins) +- Defined **end conditions** (e.g., game ends, player quits, or player fails) +- Optional **metadata** you can attach along the way (score, outcome, time spent, etc.) + +All journey maps must be reviewed and approved prior to activation to ensure they align with Reddit’s platform guidelines. + +## Core events and API structure + +The SDK provides a telemetry client via `@devvit/analytics/client/reddit`. + +### Events + +All events sent from the client are forwarded to the server, where they are enriched with standard metadata (such as app, installation, user, and post context) before being emitted as analytics events. You should set each event to fire with the corresponding trigger. + +| Event Type | Trigger | Required Fields | Optional Fields | +| ------------------------- | ----------------------------------------------------------------- | -------------------- | --------------------------------- | +| **`App.Ready`** | Fire when the game has finished loading and is interactive. | None | None | +| **`Journey.Start`** | Fire when the user explicitly begins a session (not on app load). | None | None | +| **`Journey.Progress`** | Fire when the user reaches a meaningful milestone. | `progress` (0.0–1.0) | `action`, `actionDetails` | +| **`Journey.Interaction`** | Fire for granular, stateless user interactions. | `action` | `actionDetails` | +| **`Journey.End`** | Fire when the session concludes. | None | `complete`, `game { win, score }` | + +### Journey ID handling + +The client automatically manages the `journeyId`. You do **not** need to pass it to any method. + +- A journey ID is generated server-side when `startJourney()` is called +- The client stores it in `sessionStorage` for the duration of the browser session +- Calls to `progress()`, `interaction(),` and `end()` automatically include the active journey ID + +### Auto-start behavior + +If `progress()` or `interaction()` is called before a journey has started, the client will automatically start a new journey. + +### Ending a journey + +Calling `endJourney()`: + +- Sends a `Journey.End` event +- Clears the stored journey ID from `sessionStorage` + +This allows a new journey to begin within the same session. + +## Game-specific use cases + +This example shows how a simple word game session can be represented as a Journey using four key events: `app.ready`, `journey.start`, `journey.interaction`, and `journey.end`. Together, these events capture the full lifecycle of a single play session, from when the game finishes loading to when the player completes the puzzle. + +The screenshots illustrate a typical flow: + +1. The game loads and signals it’s ready (`app.ready`). + +![Journeys-1](../../assets/analytics/journeys-1.png) + +2. The player begins playing a new word challenge (`journey.start`). + +![Journeys-2](../../assets/analytics/journeys-2.png) + +3. The player pauses mid-game (`journey.interaction`). + +![Journeys-3](../../assets/analytics/journeys-3.png) + +4. The player completes the puzzle (`journey.end`). + +![Journeys-4](../../assets/analytics/journeys-4.png) + +By instrumenting these moments, you can track session boundaries, understand player behavior during gameplay, and measure completion outcomes for the word game experience. See more example scenarios below. + +### Scenario 1: standard level-based game + +| Step | Player Action | Event Fired | Notes | +| :---: | ------------------------------------- | ----------------------- | ----------------------------- | +| **1** | Game loads | **App.Ready** | Game is fully interactive | +| **2** | Player clicks “Start Game” | **Journey.Start** | Begins a new session | +| **3** | Player completes Level 1 (of 5 levels) | **Journey.Progress** | `progress: 0.2` | +| **4** | Player opens inventory | **Journey.Interaction** | `action: "menu_opened"` | +| **5** | Player completes Level 2 | **Journey.Progress** | `progress: 0.4` | +| **6** | Player reaches final level | **Journey.Progress** | `progress: 0.9` | +| **7** | Player defeats final boss | **Journey.End** | `complete: true`, `win: true` | + +### Scenario 2: player fails mid-game + +| Step | Player Action | Event Fired | Notes | +| :---: | ---------------------------- | -------------------- | ------------------------------- | +| **1** | Game loads | **App.Ready** | Game is fully interactive | +| **2** | Player clicks “Start Game” | **Journey.Start** | Begins a new session | +| **3** | Player completes early level | **Journey.Progress** | `progress: 0.3` | +| **4** | Player dies | **Journey.End** | `complete: false`, `win: false` | + +### Scenario 3: early exit / abandonment + +| Step | Player Action | Event Fired | Notes | +| :---: | -------------------------- | ----------------------- | ------------------------- | +| **1** | Game loads | **App.Ready** | Game is fully interactive | +| **2** | Player clicks “Start Game” | **Journey.Start** | Begins a new session | +| **3** | Player pauses | **Journey.Interaction** | `action: "pause_clicked"` | +| **4** | Player quits game | **Journey.End** | `complete: false` | + +## Guidelines + +To ensure the integrity and quality of the telemetry stream, developers must follow these guidelines. + +The platform may enforce validation checks to detect anomalous or exploitative event patterns. Failure to comply may result in delayed app approval or, in severe cases, removal from the platform. + +### Event triggering + +Events must reflect **intentional, committed user actions**. + +- **Trigger on final commitment**. + + - Fire events only after a user has completed an action. + - For interactions, this typically means using `mouseUp` or `touchEnd` (not initial input). + +- **Avoid passive triggers** + - Do not track views as journeys. + - Do not use `Journey.Start` to record page or app views. + - `Journey.Start` must represent an explicit user action (like pressing “Play”) + - **Do not fire on pre-commitment input** + - Avoid early input events such as `mouseDown` or `touchStart` + - These interactions may be accidental or canceled before completion + +### App allowlist + +Telemetry is restricted by a server-side allowlist. Only approved apps can emit journey events. Requests from non-allowlisted apps will get a message that the event was dropped. + +### Platform constraints + +- **Devvit Web only**: Telemetry is only supported in Devvit Web apps (WebView). +- **Privacy**: Do **not** include PII (Personally Identifiable Information) in any user-defined fields (including `action` and `actionDetails`). You are responsible for ensuring all emitted data complies with privacy standards. + +## Getting started + +Follow these steps to implement journey tracking in your app. + +### Server events + +You can send events solely on the backend and use the front‑end only to establish and pass along the journeyId. To do this, thread the active `journey ID` from your front‑end to your backend routes. + +``` +import { telemetry } from '@devvit/analytics/client/reddit'; + +export async function submitScore(score: number): Promise { + const journeyId = telemetry.getActiveJourneyId(); + + const response = await fetch('/api/score', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(journeyId ? { 'x-devvit-journey-id': journeyId } : {}), + }, + body: JSON.stringify({ score }), + }); + + const data = (await response.json()) as { journeyId?: string }; + + if (data.journeyId) { + telemetry.setJourneyId(data.journeyId); + } +} +``` + +On the server, read the incoming `journeyId` and use it for correlation in your own route. + +``` +import express from 'express'; +import { telemetry } from '@devvit/analytics/server/reddit'; + +const app = express(); + +app.use(express.json()); + +app.post('/api/score', async (req, res) => { + const journeyIdHeader = req.header('x-devvit-journey-id'); + const journeyId = typeof journeyIdHeader === 'string' ? journeyIdHeader : ''; + + console.log('score event', { + journeyId, + score: req.body.score, + }); + + await telemetry.endJourney({ + journeyId, + complete: true, + game: { win: true, score: req.body.score }, + }); + + res.json({ ok: true }); +}); + +``` + +### Client events + +If you don’t want to manually send server-events, you can use the generic client side events. In this case, the `JourneyId` is handled. In this case, you won’t need to pass a `JourneyId` when calling progress and so forth. You also won’t need `telemetry.getActiveJourneyId()` unless you’re curious about that data. + +Note: This also requires using the route adapters provided in `@devvit/analytics/server/reddit` + +``` +// client +import { telemetry } from '@devvit/analytics/client/reddit'; + +const activeJourneyId = telemetry.getActiveJourneyId(); + +await telemetry.progress({ + progress: 0.5, + action: 'level_progress', +}); + +``` + +``` +// server +import { createTelemetryRouter } from '@devvit/analytics/server/reddit'; +app.use(createTelemetryRouter()); + +``` diff --git a/docs/capabilities/notifications/adding-streaks.md b/docs/capabilities/notifications/adding-streaks.md new file mode 100644 index 00000000..b8d17776 --- /dev/null +++ b/docs/capabilities/notifications/adding-streaks.md @@ -0,0 +1,768 @@ +# Adding Streaks + +Streaks are valuable to app developers because they turn occasional use into habitual behavior. By rewarding consecutive days of activity, streaks increase retention, strengthen user identity and investment in the app, and make push notifications more effective through loss aversion (“don’t break your streak”). For devs, that usually translates into higher engagement, lower churn, and better long-term monetization. + +![Streak PN](../../assets/notifications/visible-progress.png) + +:::note +This is currently an experimental feature, and you'll need to [apply](https://docs.google.com/forms/d/e/1FAIpQLScB3eXHVCBf3kyHueyf3G_raxH9_BsCGiXyGjQOOmPxWz6fEg/viewform?usp=publish-editor) for a spot in our beta program to implement push notifications in your app. +::: + +## **Recommended streak logic** + +This section explains how streaks work in [Syllo](https://www.reddit.com/r/syllo/) in a practical, implementation-focused way. + +### TL;DR + +- A streak is tracked as one bit per day in Redis. +- The bit is set when a logged-in user completes a puzzle and the server receives `completionTimeMs`. +- The streak day is based on the puzzle post's creation date in UTC, not the client device clock. +- The current streak is calculated by walking backward through consecutive completed days. + +### Mental model + +You can think of streaks as a yearly attendance sheet: + +- one Redis key per user per year: `streaks::` +- each day in the year maps to an index, where `0 = Jan 1` +- `1` means the user completed at least one eligible puzzle that day +- `0` means they did not + +### End-to-end flow + +#### 1\. User finishes a puzzle in the client + +When all words are solved, the client sends a completion call: + +```ts +// src/client/Game.tsx +verifyWordsMutation.mutate({ + problemId: problemData.problemId, + sessionId: problemData.analyticsSessionId, + submissions: [], + completionTimeMs: timerState.elapsedTime, +}); +``` + +#### 2\. Server records completion and updates streak state + +`verifyWords` handles completion, updates related stats, and then calls `setStreakCompletion`: + +```ts +// src/server/routes/trpc.ts (verifyWords mutation) +if (completionTimeMs && typeof completionTimeMs === "number" && userId) { + const now = new Date(); + await setUserCompletion(userId, postId, completionTimeMs); + await incrementPlayerCount(postId); + await addToLeaderboard(postId, userId, completionTimeMs); + await updateAverageCompletionTime(postId, completionTimeMs); + await setStreakCompletion({ userId, date: now, postId }); +} +``` + +#### 3\. Streak service maps completion to a day bit + +The service looks up the Reddit post and uses the post creation date to determine the year and day index: + +```ts +// src/server/services/streakService.ts +const post = await reddit.getPostById(postId); +const postCreatedAtDate = new Date(post.createdAt); +const year = postCreatedAtDate.getUTCFullYear(); +const dayOfYear = getDayOfYear(postCreatedAtDate); +await setStreakCompletionBit(userId, dayOfYear, year, completed); +``` + +#### 4\. Bit is written in Redis + +```ts +// src/server/redisService.ts +const streaksKey = (userId: string, year: number) => + `streaks:${userId}:${year}`; + +export async function setStreakCompletionBit( + userId: string, + offset: number, + year: number, + completed: boolean, +) { + UserIdSchema.parse(userId); + const key = streaksKey(userId, year); + await redis.bitfield(key, "set", "u1", offset, completed ? 1 : 0); +} +``` + +#### 5\. Client reads and displays the streak + +```ts +// src/client/Game.tsx +const { data: streakData } = useQuery( + trpc.getStreak.queryOptions(undefined, { enabled: !!context.userId }) +); + +
+ +``` + +### How current streak is calculated + +`getCurrentStreak(userId)` in `src/server/services/streakService.ts` works like this: + +1. Load the current year's bitset. +2. Start from today if today is completed; otherwise start from yesterday. +3. Count backward until the first day that is not completed. +4. If needed, continue into the previous year. + +Core loop: + +```ts +for (let i = dayToStartChecking; i >= 0; i--) { + if (isBitSet(currentYearBuffer, i)) { + currentStreak++; + } else { + break; + } +} +``` + +#### Full code for `getCurrentStreak()` + +```ts +/** + * Calculates the current streak using your custom client's getBuffer method. + */ +export async function getCurrentStreak(userId: string): Promise { + const today = new Date(); + const year = today.getUTCFullYear(); + const dayOfYear = getDayOfYear(today); + + // Call your client's getBuffer method. The result is `Buffer | undefined`. + const currentYearData = await getYearStreakBuffer(userId, year); + // The isBitSet helper expects `Buffer | null`, so we convert `undefined` to `null`. + const currentYearBuffer = currentYearData ?? null; + + let currentStreak = 0; + let dayToStartChecking = dayOfYear; + + if (!isBitSet(currentYearBuffer, dayOfYear)) { + dayToStartChecking = dayOfYear - 1; + } + + for (let i = dayToStartChecking; i >= 0; i--) { + if (isBitSet(currentYearBuffer, i)) { + currentStreak++; + } else { + break; + } + } + + // Handle cross-year streaks + // Case A: It's Jan 1 and user hasn't played today -> continue into prior year directly + if (dayToStartChecking < 0) { + const priorYear = year - 1; + const priorYearData = await getYearStreakBuffer(userId, priorYear); + const priorYearBuffer = priorYearData ?? null; + + if (priorYearBuffer) { + const isLeap = new Date(priorYear, 1, 29).getDate() === 29; + const lastDayOfPriorYear = isLeap ? 365 : 364; + + for (let i = lastDayOfPriorYear; i >= 0; i--) { + if (isBitSet(priorYearBuffer, i)) { + currentStreak++; + } else { + break; + } + } + } + } + // Case B: Current year is fully contiguous from day 0 to dayToStartChecking + else if (currentStreak > 0 && currentStreak === dayToStartChecking + 1) { + const priorYear = year - 1; + const priorYearData = await getYearStreakBuffer(userId, priorYear); + const priorYearBuffer = priorYearData ?? null; + + if (priorYearBuffer) { + const isLeap = new Date(priorYear, 1, 29).getDate() === 29; + const lastDayOfPriorYear = isLeap ? 365 : 364; + + for (let i = lastDayOfPriorYear; i >= 0; i--) { + if (isBitSet(priorYearBuffer, i)) { + currentStreak++; + } else { + break; + } + } + } + } + + return currentStreak; +} +``` + +### Important edge cases + +#### Multiple posts on the same day + +If one post is completed and another is not, the day should not be accidentally cleared. + +The service avoids clearing an already completed day in this mixed-result scenario: + +```ts +if (completed === false) { + return; +} +``` + +#### Old posts should not affect today's streak + +Updates for older posts are skipped during normal gameplay: + +```ts +const diffTime = Math.abs(now.getTime() - postCreatedAtDate.getTime()); +const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 26)); +if (diffDays > 1) { + return; +} +``` + +#### Cross-year streak continuity + +Streaks can continue from Dec 31 into Jan 1 in two cases: + +- Jan 1 is unplayed, so counting continues from the prior year tail. +- The current year is contiguous from day 0 through the current check day. + +```ts +if (dayToStartChecking < 0) { + // Jan 1 not played -> continue into prior year +} else if (currentStreak > 0 && currentStreak === dayToStartChecking + 1) { + // current year contiguous from day 0 -> continue into prior year +} +``` + +### API surface + +#### `getStreak` + +- Route: `src/server/routes/trpc.ts` +- Returns: current streak number for the logged-in user +- Used by: header and splash in the client + + #### `getStreakDetails` + +- Route: `src/server/routes/trpc.ts` +- Returns: + - `streak`: current streak + - `bestStreak`: longest streak in the current year + - `totalSolved`: number of completed days in the current year + +### Recommended test coverage + +Cover at least the following streak scenarios: + +- user has not played and returns `0` +- standard backward counting from today or yesterday +- Jan 1 cross-year behavior +- same-day multi-post safety behavior +- completion-day set behavior +- total solved days and longest streak calculations + Example: + +```ts +const year = now.getUTCFullYear(); +const today = getDayOfYear(now); +await setStreakCompletionBit(userId, today, year, true); +await setStreakCompletionBit(userId, today - 1, year, true); + +const streak = await getCurrentStreak(userId); +expect(streak).toBe(2); +``` + +### Practical summary + +- A streak day is represented by a single bit in a yearly Redis bitset. +- Completion writes happen in `verifyWords` when the puzzle is fully solved. +- Day identity is tied to the puzzle post date in UTC, not the client device day. +- Current streak is calculated from backward-consecutive completed days, with explicit cross-year handling. + +### Troubleshooting checklist + +If a streak looks wrong, check these in order: + +1. Did completion reach `verifyWords` with both `completionTimeMs` and `userId`? +2. Did `setStreakCompletion` run with a valid `postId`? +3. What is the post's `createdAt` value? This determines the day and year. +4. Does the Redis key `streaks::` have the expected bit set? +5. Is the issue near Jan 1, where cross-year logic applies, or from an old post attempt? + +## **LLM prompt** + +This is exciting new ground for us\! You can try out this prompt as a flexible starting point for implementing streak systems in your app. However, because implementations vary widely across games and backends, **we aren’t able to provide integration support or troubleshooting for custom setups**. + + + +
+Copy-paste the prompt + +\--- START COPY-PASTE PROMPT \--- + +You are a senior Devvit engineer. Add a robust daily streak system and a streak-aware push notification campaign to this Devvit app. + +The goal is to implement the feature in this repo's existing style, not to copy file names or UI from another app. Be opinionated about the underlying mechanics: + +- Use Redis as the source of truth for daily streak days. +- Use server-side completion as the only authority for awarding streak credit. +- Use Devvit's notifications API for all push notification behavior. Do not implement a custom push provider, browser push subscription, webhook-based sender, or parallel notification system. +- Use Devvit's notification opt-in APIs as the only source of truth for push subscription state. +- Use a batched scheduler job for push campaigns so large recipient lists do not run in one request. +- Keep the notification UX app-specific: expose methods that the client can call, but do not invent a full opt-in screen unless this app already has an obvious place for it. + +**First Inspect The Target Repo** + +Before writing code, inspect the target app and identify: + +- Server entrypoint and router setup. +- Existing Redis helper module, if any. +- Existing tRPC, REST, or server action layer used by the webview. +- Existing game/content completion mutation or event. +- Existing scheduler configuration in `devvit.json`. +- Existing tests and test style. +- Existing client API utilities. +- Existing notification usage, if any. + +After inspection, implement using the repo's local patterns. Suggested module names are `streakService`, `notificationService`, `schedulerRoutes`, and `redisService`, but only use them if they fit the repo. + +Before coding, produce a short implementation mapping in your working notes or response: + +- `COMPLETION_EVENT`: where completion is validated server-side. +- `CONTENT_ID`: the newly created daily Reddit post id, if this app creates daily posts. Use this as the notification link target. If the app does not create daily posts, use the stable content id or route that opens today's playable item. +- `CONTENT_CREATED_AT`: the daily Reddit post's `createdAt`, if this app creates daily posts. Fetch it server-side or persist it when the post is created. If the app does not create daily posts, use a server-written `createdAt` timestamp stored with the daily content metadata. +- `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK`: ask the developer before coding whether completions of older/archive content should count for streaks. Do not assume this value. +- `REDIS_HELPERS`: where Redis access should live. +- `STREAK_APIS`: where `getStreak` and optional `getStreakDetails` should be exposed. +- `PUSH_OPT_IN_APIS`: where `getPushState` and `setPushState` should be exposed. +- `SCHEDULER_CONFIG`: where the `pn` scheduler task should be registered. +- `SCHEDULER_ENDPOINT`: where the push campaign endpoint should live. +- `TEST_LOCATIONS`: where streak, notification, and scheduler tests should be added. + +If the completion event cannot be identified with high confidence, stop and ask for clarification before editing. Before writing code, also ask the developer to choose `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK: true` or `false` unless they already provided that answer. For content id and timestamp, use the defaults above before asking. + +**App-Specific Values To Fill In** + +Use these defaults unless the target app clearly works differently: + +- `COMPLETION_EVENT`: the server-side event/mutation that means a user completed a playable daily item. +- `CONTENT_ID`: default to the Reddit post id for the newly created daily content. This should be a runtime value that changes whenever new daily content is created. In Syllo, each newly posted puzzle schedules its notification with that new Reddit post id. +- `CONTENT_CREATED_AT`: default to that Reddit post's `createdAt`. When recording streak completion, fetch the post by `CONTENT_ID` server-side and use `post.createdAt`, or use the same `createdAt` value persisted during post creation. Do not use the client clock or completion request timestamp. +- `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK`: ask before starting. If `true`, completing older/archive content records the streak bit for that content's UTC day. If `false`, only current daily content can award streak credit. +- `NEW_CONTENT_NOTIFICATION_DELAY_MS`: default to 60 minutes after content is posted unless the app has a better time. +- `STREAK_NOTIFICATION_TITLE`: example, `Today's challenge is ready!`. +- `STREAK_NOTIFICATION_BODY`: example, `Play now to keep your {{streak}}-day streak alive.` +- `NON_STREAK_NOTIFICATION_BODY`: example, `Jump back in and play today's challenge.` + +**Default Content Identity Rules** + +For most Devvit games, the notification target and streak day should come from the same daily content object: + +- If the app creates one Reddit post per daily game/challenge, use that post id as `CONTENT_ID`. +- Schedule the notification immediately after creating that post, passing `params: { link: post.id }`. +- When a user completes the game, use the current `postId`/`CONTENT_ID` to fetch the post server-side and anchor the streak bit to `post.createdAt`. +- If the app stores daily content metadata in Redis when it posts content, it is also fine to persist `{ contentId, createdAt }` then reuse that server-written `createdAt`. +- Only if the app has no per-day Reddit post should you fall back to a stable app route or stored daily-content id, with a server-written `createdAt` timestamp. + +**Architecture To Implement** + +Implementation flow: + +1. When daily content is created, schedule a `pn` job for the new-content notification. +2. When a user completes content, validate completion server-side. +3. Record the streak bit for the content's canonical UTC day. +4. When the `pn` job runs, page opted-in users. +5. For each opted-in user, compute current streak from Redis. +6. Split recipients into streak and non-streak groups. +7. Enqueue templated notifications with the content link. + +**Redis Data Model** + +Implement daily streak storage as one Redis bitmap per user per UTC calendar year. This is the same storage shape Syllo uses: a key is scoped to both `userId` and `year`, and each bit in that year's value represents whether the user completed the canonical content for one UTC day. + +Use the repo's existing Redis naming style, but default to a versioned key: + +```ts +const STREAK_VERSION = 1; +const streaksKey = (userId: string, year: number) => + `${STREAK_VERSION}:streaks:${userId}:${year}`; +``` + +Store one bit per UTC day-of-year: + +- Jan 1 is bit `0`. +- Dec 31 is bit `364` in non-leap years. +- Dec 31 is bit `365` in leap years. +- Use Redis `BITFIELD` with `u1` values. +- Read full-year state as a `Buffer` for efficient streak calculation. + +Implement helpers equivalent to: + +```ts +export function getDayOfYear(date: Date): number { + const year = date.getUTCFullYear(); + const start = Date.UTC(year, 0, 1); + const current = Date.UTC(year, date.getUTCMonth(), date.getUTCDate()); + return Math.floor((current - start) / 86_400_000); +} + +export function isBitSet(buffer: Buffer | null, bitIndex: number): boolean { + if (!buffer || bitIndex < 0) return false; + const byteIndex = Math.floor(bitIndex / 8); + if (byteIndex >= buffer.length) return false; + const bitWithinByte = bitIndex % 8; + const byte = buffer[byteIndex]!; + return (byte & (1 << (7 - bitWithinByte))) !== 0; +} +``` + +Implement Redis helpers: + +- `setStreakCompletionBit(userId, dayIndex, year, completed)`. +- `getStreakCompletionBit(userId, dayIndex, year)`. +- `getYearStreakBuffer(userId, year)`. + +Validate `userId`, `year`, and `dayIndex` according to the target repo's validation style before touching Redis. + +**Streak Service Behavior** + +Implement a streak service with these behaviors. + +`recordStreakCompletion` + +Record streak credit only from the server-side completion path. + +Recommended signature: + +```ts +type RecordStreakCompletionInput = { + userId: string; + contentId: string; + contentCreatedAt: Date; + completed?: boolean; + force?: boolean; +}; +``` + +Rules: + +- Default `completed` to `true`. +- If `force !== true` and `completed === false`, do nothing. A failed/incomplete action must not clear a day that was already earned. +- Anchor the streak day to `contentCreatedAt` in UTC, not to the user's device clock or request timestamp. +- Make the write idempotent. Setting the same bit to `1` multiple times must be safe and must not create duplicate counts. +- Respect `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK`. If `true`, completing content from three days ago sets the bit for that content's UTC day. If `false`, older/archive content should not award streak credit. +- `force` should be reserved for admin/backfill/repair paths. + +`getCurrentStreak` + +Current streak should be forgiving for daily content: + +- A user with no completed streak-worthy days must have streak `0`. Do not initialize new users to `1`. +- After the user's first streak-worthy completion, the streak should become `1`. +- If the user has played today, count backward from today. +- If the user has not played today, count backward from yesterday. This lets a user keep a visible streak alive during the day before they play. +- Continue counting through contiguous set bits. +- Bridge from Jan 1 into the prior year's bitmap when the current-year prefix is contiguous. +- Correctly handle leap years. + +`getTotalPlaysForYear` and `getLongestStreakForYear` + +Implement these if the app displays profile stats: + +- `getTotalPlaysForYear(userId, year = currentUTCYear)`. +- `getLongestStreakForYear(userId, year = currentUTCYear)`. + +Do not overbuild all-time stats unless the app needs them. + +**Completion Hook** + +Find the target app's server-side completion mutation/event. After the app has verified that the user truly completed streak-worthy content, call `recordStreakCompletion`. + +The streak write should happen near the app's existing completion writes, such as progress, score, leaderboard, or analytics updates. + +Recommended order: + +1. Validate `userId` and content id. +2. Validate completion server-side. +3. Persist the app's normal completion state. +4. Record the streak completion. +5. Return the updated state or allow the client to refetch streak state. + +Do not record streaks from a client-only event. + +**Streak APIs** + +Expose server APIs in the target app's API style: + +- `getStreak`: returns the current user's numeric streak. +- `getStreakDetails`: returns `{ streak, bestStreak, totalSolved }` if the app needs profile stats. + +The client can display these wherever appropriate, but do not create app-specific UI unless requested. + +**Push Opt-In API Wrappers** + +Use Devvit notifications as the source of truth. This implementation must use Devvit's notifications API from the Devvit SDK, not a custom push system. If the target repo imports directly from `@devvit/notifications`, use that package. In Devvit web server apps, the same API is commonly exposed as `notifications` from `@devvit/web/server`: + +```ts +import { notifications } from "@devvit/web/server"; + +export async function setPushNotificationState(input: { pushState: boolean }) { + if (input.pushState) { + await notifications.optInCurrentUser(); + } else { + await notifications.optOutCurrentUser(); + } +} +``` + +Expose API methods in the target app's style: + +- `getPushState`: calls `notifications.isOptedIn(userId)` and returns `{ pushState: boolean }`. +- `setPushState`: calls `notifications.optInCurrentUser()` or `notifications.optOutCurrentUser()`. + +Do not store a separate Redis opt-in boolean. The app can build any opt-in UX around these methods: post-win prompt, settings toggle, onboarding prompt, profile button, or menu action. + +**Streak-Aware Push Campaign** + +Implement one campaign for new daily content or "play again" notifications. + +Use only Devvit's notifications API for sending: + +- `notifications.listOptedInUsers` to page the Devvit opted-in audience. +- `notifications.enqueue` to send push notifications. +- Per-recipient `data` for template placeholders. + +Do not add service workers, browser Push API subscriptions, external push vendors, custom device-token storage, custom Redis opt-in lists, or app-owned notification delivery queues. + +Behavior: + +- Page opted-in users with `notifications.listOptedInUsers`. +- For each recipient, compute `getCurrentStreak(userId)` server-side. +- Split recipients into two groups: + - `streak > 0`: receive streak-preserving copy with template data `{ streak: String(streak) }`. + - `streak === 0`: receive generic return-to-play copy. +- Enqueue with `notifications.enqueue`. +- Use per-recipient `data` for template placeholders like `{{streak}}`. +- Include the canonical content/post link in each recipient. + +Recommended campaign handler shape: + +```ts +type CampaignResult = { + done: boolean; + cursor: string; +}; + +async function sendNewContentNotification(input: { + cursor: string; + count: number; + link: string; +}): Promise { + const results = await notifications.listOptedInUsers({ + after: + input.cursor.trim() === "" || input.cursor === "0" + ? undefined + : input.cursor, + limit: Math.min(1000, Math.max(1, input.count)), + }); + + const streakRecipients = []; + const freshRecipients = []; + + for (const userId of results.userIds) { + const streak = await getCurrentStreak(userId); + if (streak > 0) { + streakRecipients.push({ + userId, + link: input.link, + data: { streak: String(streak) }, + }); + } else { + freshRecipients.push({ + userId, + link: input.link, + data: {}, + }); + } + } + + await Promise.allSettled([ + streakRecipients.length > 0 && + notifications.enqueue({ + title: STREAK_NOTIFICATION_TITLE, + body: STREAK_NOTIFICATION_BODY, + recipients: streakRecipients, + }), + freshRecipients.length > 0 && + notifications.enqueue({ + title: STREAK_NOTIFICATION_TITLE, + body: NON_STREAK_NOTIFICATION_BODY, + recipients: freshRecipients, + }), + ]); + + return { done: !results.next, cursor: results.next ?? "" }; +} +``` + +Adapt types for the target repo, especially typed Reddit ids like `T2`, `T3`, or `T1` if used. + +**Scheduler Job** + +Add or reuse one generic push scheduler job. In `devvit.json`, register a task similar to: + +```json +{ + "scheduler": { + "tasks": { + "pn": { + "endpoint": "/internal/scheduler/pn" + } + } + } +} +``` + +If the app already has scheduler routes, use the existing pattern. + +The scheduler endpoint should: + +- Validate the job body with the repo's validation tool, preferably Zod if already used. +- Accept a discriminated campaign payload, even if the first implementation only supports `new-content`. +- Read push batch config from Redis: `batchSize` and `batchDelayMs`. +- Process one batch. +- If not done, call `scheduler.runJob` again with the same campaign, updated cursor, same params, and `runAt = now + batchDelayMs`. +- Log campaign start, enqueue result, next cursor, and validation failures. + +Default batch config: + +```ts +const DEFAULT_PN_CONFIG = { + batchDelayMs: 1_500, + batchSize: 200, +}; +``` + +Store it in Redis using a versioned config key, for example `v1:config:pn`, and self-heal missing values. + +**Scheduling A New-Content Push** + +When new daily content is posted or becomes available, schedule: + +```ts +await scheduler.runJob({ + name: "pn", + data: { + campaign: "new-content", + cursor: "", + params: { link: contentId }, + }, + runAt: new Date(Date.now() + NEW_CONTENT_NOTIFICATION_DELAY_MS), +}); +``` + +Use the app's canonical content id as `link`. If the app has a daily content cron, schedule the push from that content creation path. If content is created manually, schedule it from that path too. + +**Robustness Requirements** + +- Validate all inputs before Redis writes or notification sends. +- Never trust client-reported completion for streak credit. +- Use the daily post's `createdAt` for the streak day, or the server-written daily content `createdAt` if the app does not use per-day Reddit posts. +- Keep completion writes idempotent. +- Keep opt-in state in Devvit notifications, not Redis. +- Cap notification batch size. +- Use scheduler continuation for pagination. +- Avoid private or surprising personalization in notification copy. +- Add enough logging to debug campaign progress. +- If historical completions exist, add a backfill script/job or leave a clear TODO with instructions. + +**Tests To Add** + +Add tests in the target repo's test style. Cover these cases: + +Streak Storage + +- Setting a streak bit for one user/year does not affect another user/year. +- `getDayOfYear` returns `0` for Jan 1, `364` for Dec 31 in non-leap years, and `365` for Dec 31 in leap years. +- `isBitSet` reads bits consistently with Redis bitmap ordering. + +Current Streak + +- Returns `0` when there are no plays. +- Returns `1` only after the first streak-worthy completion. +- Counts backward from today when today is set. +- Counts backward from yesterday when today is not set. +- Stops at the first missing day. +- Bridges from Jan 1 into the previous year when contiguous. +- Handles leap-year prior-year endings. + +Completion Writes + +- Recording the same completion twice is safe. +- Non-forced `completed: false` does not clear an existing day. +- Forced admin/backfill write can set or clear a bit if that behavior is implemented. +- Completion uses the content timestamp, not the request timestamp. +- If `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK` is `true`, completing an archive item sets the streak bit for that archive item's UTC content day. +- If `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK` is `false`, completing an archive item does not award streak credit. + +Push Opt-In Wrappers + +- `setPushState(true)` calls `notifications.optInCurrentUser`. +- `setPushState(false)` calls `notifications.optOutCurrentUser`. +- `getPushState` calls `notifications.isOptedIn`. +- No custom push provider, browser Push API subscription, or Redis opt-in source of truth is created. + +Notification Campaign + +- Opted-in users with `currentStreak > 0` receive streak copy with `data.streak`. +- Opted-in users with `currentStreak === 0` receive generic copy. +- Non-opted-in users are not sent notifications. +- Pagination returns the next cursor and marks done when no next page exists. + +Scheduler + +- The scheduler endpoint validates campaign payloads. +- It processes one batch using configured `batchSize`. +- It schedules a continuation job when `done === false`. +- It does not schedule a continuation when `done === true`. + +**Manual Verification Checklist** + +After implementation: + +- Complete today's content as a logged-in user and confirm the streak increments. +- Start as a new user and confirm the displayed/API streak is `0` before completing any streak-worthy content. +- Complete one streak-worthy item and confirm the displayed/API streak becomes `1`. +- If `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK` is `true`, complete an archive item as a logged-in user and confirm the app records the streak bit for that archive item's content day. +- If `COUNT_ARCHIVE_COMPLETIONS_FOR_STREAK` is `false`, complete an archive item as a logged-in user and confirm it does not award streak credit. +- Refresh the client and confirm `getStreak` returns the same value. +- Complete the same content again and confirm the streak does not double-count. +- Set up an opted-in test user with a streak and run the new-content campaign; confirm streak copy is sent. +- Set up an opted-in test user without a streak and run the campaign; confirm generic copy is sent. +- Confirm the notification link opens the intended content. +- Confirm a non-opted-in user does not receive a push. +- Confirm scheduler continuation works with a small batch size like `1`. + +**Expected Final Response From The Implementing Agent** + +When finished, report: + +- Files changed and why. +- The target repo's completion path that now records streaks. +- The Redis keys/data model used. +- The scheduler task and campaign payload shape. +- The push opt-in API methods exposed. +- Tests added and test command results. +- Manual verification steps completed or not completed. +- Remaining app-specific TODOs, if any. + +\--- END COPY-PASTE PROMPT \--- + +
diff --git a/docs/capabilities/notifications/notifications-overview.md b/docs/capabilities/notifications/notifications-overview.md new file mode 100644 index 00000000..8026af37 --- /dev/null +++ b/docs/capabilities/notifications/notifications-overview.md @@ -0,0 +1,18 @@ +# Overview + +Push notifications are an experimental engagement feature designed to help bring players back into your game at the right moments. + +Push notifications can help drive engagement, increase player retention, and build habit loops for players—all good things for your game. You can trigger opt-in reminders around high-value events such as streaks, rewards availability, milestones, and live activities. + +## Beta requirements + +This is a **gated beta**, which means that you’ll need to apply to unlock the ability to use push notifications in your app. Current eligibility is aimed at established, already-engaged games rather than brand-new launches, and push notification partners are selected from active games with traction and predictable cadence. Check out our [featured games](https://www.reddit.com/r/GamesOnReddit/comments/1rydlny/games_launchpad/) to get an idea of what we’re looking for. + +## How to apply + +If you meet the beta requirements, fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLScB3eXHVCBf3kyHueyf3G_raxH9_BsCGiXyGjQOOmPxWz6fEg/viewform?usp=publish-editor) for consideration, and be sure to include: + +- The app identifier. +- Your push notification copy. We’ll do a quick review to ensure that it complies with our [Reddit Rules](https://redditinc.com/policies/reddit-rules). + +Note that spaces are limited, and not all apps that meet the criteria will be accepted. diff --git a/docs/capabilities/notifications/pn-best-practices.md b/docs/capabilities/notifications/pn-best-practices.md new file mode 100644 index 00000000..f351af31 --- /dev/null +++ b/docs/capabilities/notifications/pn-best-practices.md @@ -0,0 +1,226 @@ +# Best Practices + +Push notifications can help drive engagement, increase player retention, and build habit loops for players—all good things for your game. The examples below are from [Syllo](https://www.reddit.com/r/syllo/), a word game that integrated Push Notifications into the game experience. + +![PN Examples](../../assets/notifications/syllo-pn-examples.png) + +This guide provides instructions for implementing developer-authored push notifications for Reddit games. + +:::note +This is currently an experimental feature, and you'll need to [apply](https://docs.google.com/forms/d/e/1FAIpQLScB3eXHVCBf3kyHueyf3G_raxH9_BsCGiXyGjQOOmPxWz6fEg/viewform?usp=publish-editor) for a spot in our beta program to implement push notifications in your app. +::: + +## How it works + +### Push notification copy review + +All push notifications in this Beta must be pre-approved by Devvit admins to ensure alignment with Reddit’s notification standards and content policy (see [Effective Copy](#write-effective-copy) for tips\!). + +The Devvit team will review and approve all submissions before activation to ensure a safe and consistent experience across games. + +Notifications should be: + +- **Time-sensitive or highly important** (e.g., a new puzzle is available, a live event is starting, a challenge has ended). +- **Respectful and Reddit-appropriate**, avoiding spammy, click-bait, or overly promotional language. +- **Short and clear** — titles ≤ 60 characters, body ≤ 100 characters. +- **Transparent** about the context of the notification (what the user will see when they tap). + +You can send a maximum of two push notifications per day per user. + +### Opt-in / opt-out UX + +Push notifications are opt-in only. and this is enforced at the API level. In the future, additional opt-out controls will also be available in Reddit settings. + +Games must provide an in-game notifications control that: + +- Allows users to explicitly turn notifications on and off +- Clear text indicating the user’s current state (on or off) on the initial screen + +#### UX best practices + +- Use Reddit’s notification-on and notification-off icons +- Clearly indicate if game notifications are on or off +- Always provide an easy way to toggle on or off notifications from your initial app view or a settings page +- Show visual confirmation of actions, like adding a “Notifications enabled” toast or change to the button styling + +#### Component examples + +![Components](../../assets/notifications/component-examples.png) + +## Designing high-quality notifications + +Push notifications are most effective when strong content and strong copy work together. High-quality messages start with meaningful in-game events and use clear, motivating language to bring that value to life. + +### Create meaningful events + +Your push notifications should deliver clear, immediate value to players. + +- **Make player progress visible**. Streaks make player progress tangible and gives players a clear, low-effort goal: show up, keep the streak alive, and continue progressing. **Make sure to include the number of days in the streak**. This makes the player’s progress concrete and strengthens their motivation to return to keep it going. + +![PN Examples](../../assets/notifications/visible-progress.png) + +- **Provide tangible player benefits**. Tie notifications to real outcomes, like rewards ready to claim, streak milestones, live games, or limited-time challenges. Give the user a clear payoff for returning to the game. High-value events drive high conversion. If there’s no clear payoff, it’s better not to send the notification at all. + +![PN Examples](../../assets/notifications/tangible-benefits.png) + +- **Layer urgency onto broadly relevant moments**. Create momentum by promoting events that are time-sensitive (“happening now” or “ending soon”) and matter to a wide audience, like a daily puzzle going live or the final hour of a tournament. + +![PN Examples](../../assets/notifications/layer-urgency.png) + +- **Treat pushes as a limited resource**. Use notifications selectively for moments where value is obvious at a glance, like completing a weekly challenge, collecting a reward, joining a live match, or entering a newly unlocked mode. These are the moments most likely to drive immediate play and long-term retention. + +### Optimize timing + +Send push notifications when players are most likely to engage with your game. + +- **Localize delivery by user time zone** + + - Schedule sends using the player’s local time, not a single global batch time. + - Treat night-time sends as disallowed by default; players are more likely to mute or opt out if they’re woken up or interrupted late. + +- **Aim for late afternoon to early evening** + + - Platform data shows peak PN opens in late afternoon and early evening by GEO. Use this as the default window if you don’t have game-specific signals. + - Start with a conservative window like **16:00–21:00 local time**, then refine based on your game’s metrics (CTR and disable rates). + +- **Align timing to moments of natural intent** + + - For streak reminders, send close to when players typically play (e.g., a few hours before their usual daily play time), not as a “last second” midnight panic. + - For event and reward PNs, send: + - Shortly before the value becomes available (e.g., event starting soon, reward about to unlock), or + - When the value is immediately redeemable and you can deeplink straight into the relevant screen. + - Avoid “just because it’s morning” or daily cron-style sends; timing should always correspond to a clear in-game reason to come back right now. + +- **Use timing metrics to iterate** + - Track **CTR by hour-of-day and day-of-week** in local time buckets for your game. + - Watch **notification disable rates** after bursts of sends; spikes usually mean you’re hitting players at the wrong time (too early, too late, or too often). + - If you don’t have enough volume for fine-grained experiments, stick to: + - No night-time delivery + - Late afternoon/evening windows + - Only sending when a concrete, time-bound value is available (streak, event, reward). + +### Write effective copy {#write-effective-copy} + +Push notifications act as **motivational nudges** that get to the crux of why users are playing your game. Here are some ways you can incorporate these nudges into your game. + +- **Add behavioral triggers**. Behavioral triggers respond to real user actions, like completing tasks, returning after a break, or reaching milestones. This makes each message feel timely and personal. Examples might include: + + - “You’re 1 game away from finishing your weekly challenge\!” + - “You were in the top 5% last week—can you do it again?” + - “You have 2 new puzzles waiting for you\!” + +- **Engage with social dynamics**. Highlighting status changes and community milestones inspire connection and friendly competition. Examples might include: + + - “Your team just moved up a division\! Jump in to keep the momentum going\!” + - “Someone just stole your place on the leaderboard. Want it back?” + +- **Leverage personal motivation.** The user’s history with the game reinforces progress. Examples might include: + - “Your crops are thriving\! Come collect your rewards\!” + - “You’re close to genius rank\! Solve 3 more puzzles to claim your crown\!” + - “You’re one mission away from unlocking your next rank\! Time to jump back into action\!” + +To learn more about creating deeper engagement loops, check out the best practices for [building community games](https://developers.reddit.com/docs/guides/best-practices/community_games). + +## Adding push notifications to your app + +### Step 1: Update the push notification module + +In your terminal, navigate to your project directory and run this command to update the push notification to the latest release. + +``` +npm install @devvit/notifications +``` + +### Step 2: Import the push notification module + +``` +import { notifications } from '@devvit/notifications'; +``` + +**Note**: If you already have the PN module, enter +`npm install @devvit/notifications@next` to get the latest version. + +### Step 3: Use bulk push notification endpoint with templating + +To send a push notification to a group of users, you can use the double curly brackets ( { { } } ) to reference variables in a Mustache template. + +``` +await notifications.enqueue({ + title: 'Hello {{name}}!', + body: 'You have {{score}} new points.', + recipients: [ + { + userId: 't2_abc123', + link: 't3_xyz987', + data: { + name: 'Alex', + score: '42', + }, + }, + { + userId: 't2_def456', + link: 't3_xyz987', + data: { + name: 'Jordan', + score: '7', + }, + }, + { + userId: 't2_ghi789', + link: 't3_xyz987', + data: { + name: 'Sam', + score: '13', + }, + }, + ], +}); +``` + +**Note:** Mustache templating is optional. Here's a simplified example without it: + +``` +await notifications.enqueue({ + title: 'Winner!', + body: 'Congrats on your win', + recipients: [ + { + userId: 't2_abc123', + link: 't3_xyz987', + }, + ], +``` + +**Note**: If the app hasn’t been published, you can only send push notifications to yourself for testing. **Pre-release apps in testing are not subject to the rate-limits below**. + +Once your app is published, you can pass different data per user for the same template. For each app, you can send: + +- 2 push notifications per user per day\* +- Up to 25K per app per day + +If you need higher limits, let us know. + +### Step 4: opt-in / opt-out + +Users will be able to opt in or out of receiving notifications triggered by a button in your UI: + +``` +await notifications.optInCurrentUser(); +await notifications.optOutCurrentUser(); +``` + +You will also be able to retrieve a list of users who have opted in (if not managing it manually): + +``` +//This will just return the first 1000 users +const recipients = await notifications.listOptedInUsers(); + +const recipients = await notifications.listOptedInUsers({ + after: '1764876078573:t2_ltrlsg7l', + limit: 100, +}); +``` + +The default and maximum limit is 1,000. + +Usernames are returned in chronological order according to opt-in time, starting after the specified cursor. If the cursor is not found, results begin from the earliest opt-in time. diff --git a/docs/capabilities/server/cache-helper.md b/docs/capabilities/server/cache-helper.md new file mode 100644 index 00000000..44ce10a1 --- /dev/null +++ b/docs/capabilities/server/cache-helper.md @@ -0,0 +1,207 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Cache Helper + +Cache helper lets you build a more performant app by reducing the number of server side calls for the same data. You can create a short-term cache that stores JSON objects in your Devvit app for a limited amount of time. This is valuable when you have many clients trying to get the same data, for example a stock ticker value or a sports score. + +Under the covers, it's Redis plus a local in-memory write-through cache. This provides a pattern for fetching data without involving a scheduler and allows small time-to-live (TTL, ~1 second). Cache helper lets the app make one request for the data, save the response, and provide this response to all users requesting the same data. + +:::warning +**Do not cache sensitive information**. Cache helper randomly selects one user to make the real request and saves the response to the cache for others to use. You should only use cache helper for non-personalized fetches, since the same response is available to all users. +::: + +## Usage + +You can import cache helper from `@devvit/web/server` in your server source files. The cache helper is not available client-side, so you will see an error if you try to import it in client source files. + +```tsx +import { cache } from "@devvit/web/server"; +``` + +## Parameters + +The cache takes a key and a TTL: + +| **Parameters** | **Description** | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `key` | This is a string that identifies a cached response. Instead of making a real request, the app gets the cached response with the key you provide. Make sure to use different keys for different data. For example, if you’re saving post-specific data, add the postId to the cache key, like this: `post_data_${postId})`. | +| `ttl` | Time to live is the number of **seconds** the cached response is expected to be relevant. Once the cached response expires, it will be voided and a real request is made to populate the cache again. You can treat it as a threshold, where ttl of 30 would mean that a request is done no more than once per 30 seconds. | + +## Example + +Here’s a way to set up in-app caching instead of using scheduler or interval to fetch. + + + + +```tsx title="server/index.ts" +import { Hono } from "hono"; +import { + cache, + context, + createServer, + getServerPort, + reddit, +} from "@devvit/web/server"; + +type SubredditResponse = { + type: "subreddit"; + subreddit: string; +}; + +type SubredditErrorResponse = { + status: "error"; + message: string; +}; + +const app = new Hono(); + +app.get("/api/subreddit", async (c) => { + const { postId } = context; + + if (!postId) { + console.error("API Subreddit Error: postId not found in devvit context"); + return c.json( + { + status: "error", + message: "postId is required but missing from context", + }, + 400, + ); + } + + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error("Subreddit is required but missing from context"); + } + return subreddit.name; + }, + { + key: "current_subreddit", + ttl: 24 * 60 * 60, // expire after one day. + }, + ); + console.log(`Current subreddit: ${subredditName}`); + + return c.json({ + type: "subreddit", + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = "Unknown error during subreddit retrieval"; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; + } + return c.json( + { status: "error", message: errorMessage }, + 400, + ); + } +}); + +const server = createServer(app); +server.on("error", (err) => console.error(`server error; ${err.stack}`)); +server.listen(getServerPort()); +``` + + + + +````tsx title="server/index.ts" + import express from "express"; + import { + cache, + createServer, + context, + getServerPort, + reddit, + } from "@devvit/web/server"; + + type SubredditResponse = { + type: "subreddit"; + subreddit: string; + }; + + type SubredditErrorResponse = { + status: "error"; + message: string; + }; + + const app = express(); + + // Middleware for JSON body parsing + app.use(express.json()); + // Middleware for URL-encoded body parsing + app.use(express.urlencoded({ extended: true })); + // Middleware for plain text body parsing + app.use(express.text()); + + const router = express.Router(); + + router.get( + "/api/subreddit", + async (_req, res): Promise => { + const { postId } = context; + + if (!postId) { + console.error("API Subreddit Error: postId not found in devvit context"); + res.status(400).json({ + status: "error", + message: "postId is required but missing from context", + }); + return; + } + + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error("Subreddit is required but missing from context"); + } + return subreddit.name; + }, + { + key: `current_subreddit`, + ttl: 24 * 60 * 60 // expire after one day. + } + ); + console.log(`Current subreddit: ${subredditName}`); + + res.json({ + type: "subreddit", + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = "Unknown error during subreddit retrieval"; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; + } + res.status(400).json({ status: "error", message: errorMessage }); + } + } + ); + + app.use(router); + + const server = createServer(app); + server.on("error", (err) => console.error(`server error; ${err.stack}`)); + server.listen(getServerPort()); + ``` + + + +```` diff --git a/docs/capabilities/server/cache-helper.mdx b/docs/capabilities/server/cache-helper.mdx deleted file mode 100644 index a6e4e5ce..00000000 --- a/docs/capabilities/server/cache-helper.mdx +++ /dev/null @@ -1,198 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Cache helper - -Cache helper lets you build a more performant app by reducing the number of server side calls for the same data. You can create a short-term cache that stores JSON objects in your Devvit app for a limited amount of time. This is valuable when you have many clients trying to get the same data, for example a stock ticker value or a sports score. - -Under the covers, it's Redis plus a local in-memory write-through cache. This provides a pattern for fetching data without involving a scheduler and allows small time-to-live (TTL, ~1 second). Cache helper lets the app make one request for the data, save the response, and provide this response to all users requesting the same data. - -:::warning -**Do not cache sensitive information**. Cache helper randomly selects one user to make the real request and saves the response to the cache for others to use. You should only use cache helper for non-personalized fetches, since the same response is available to all users. -::: - - -## Usage - -You can import cache helper from `@devvit/web/server` in your server source files. The cache helper is not available client-side, so you will see an error if you try to import it in client source files. - -```tsx -import { cache } from '@devvit/web/server'; -``` - -## Parameters - -The cache takes a key and a TTL: - -| **Parameters** | **Description** | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `key` | This is a string that identifies a cached response. Instead of making a real request, the app gets the cached response with the key you provide. Make sure to use different keys for different data. For example, if you’re saving post-specific data, add the postId to the cache key, like this: `post_data_${postId})`. | -| `ttl` | Time to live is the number of **seconds** the cached response is expected to be relevant. Once the cached response expires, it will be voided and a real request is made to populate the cache again. You can treat it as a threshold, where ttl of 30 would mean that a request is done no more than once per 30 seconds. | - -## Example - -Here’s a way to set up in-app caching instead of using scheduler or interval to fetch. - - - - - ```tsx title="server/index.ts" - import { Hono } from 'hono'; - import { cache, context, createServer, getServerPort, reddit } from '@devvit/web/server'; - - type SubredditResponse = { - type: 'subreddit'; - subreddit: string; - }; - - type SubredditErrorResponse = { - status: 'error'; - message: string; - }; - - const app = new Hono(); - - app.get('/api/subreddit', async (c) => { - const { postId } = context; - - if (!postId) { - console.error('API Subreddit Error: postId not found in devvit context'); - return c.json( - { - status: 'error', - message: 'postId is required but missing from context', - }, - 400 - ); - } - - try { - const subredditName = await cache( - async () => { - const subreddit = await reddit.getCurrentSubreddit(); - if (!subreddit) { - throw new Error('Subreddit is required but missing from context'); - } - return subreddit.name; - }, - { - key: 'current_subreddit', - ttl: 24 * 60 * 60, // expire after one day. - } - ); - console.log(`Current subreddit: ${subredditName}`); - - return c.json({ - type: 'subreddit', - subreddit: subredditName, - }); - } catch (error) { - console.error(`API Subreddit Error for post ${postId}:`, error); - let errorMessage = 'Unknown error during subreddit retrieval'; - if (error instanceof Error) { - errorMessage = `Subreddit retrieval failed: ${error.message}`; - } - return c.json({ status: 'error', message: errorMessage }, 400); - } - }); - - const server = createServer(app); - server.on('error', (err) => console.error(`server error; ${err.stack}`)); - server.listen(getServerPort()); - ``` - - - - - ```tsx title="server/index.ts" - import express from "express"; - import { - cache, - createServer, - context, - getServerPort, - reddit, - } from "@devvit/web/server"; - - type SubredditResponse = { - type: "subreddit"; - subreddit: string; - }; - - type SubredditErrorResponse = { - status: "error"; - message: string; - }; - - const app = express(); - - // Middleware for JSON body parsing - app.use(express.json()); - // Middleware for URL-encoded body parsing - app.use(express.urlencoded({ extended: true })); - // Middleware for plain text body parsing - app.use(express.text()); - - const router = express.Router(); - - router.get( - "/api/subreddit", - async (_req, res): Promise => { - const { postId } = context; - - if (!postId) { - console.error("API Subreddit Error: postId not found in devvit context"); - res.status(400).json({ - status: "error", - message: "postId is required but missing from context", - }); - return; - } - - try { - const subredditName = await cache( - async () => { - const subreddit = await reddit.getCurrentSubreddit(); - if (!subreddit) { - throw new Error("Subreddit is required but missing from context"); - } - return subreddit.name; - }, - { - key: `current_subreddit`, - ttl: 24 * 60 * 60 // expire after one day. - } - ); - console.log(`Current subreddit: ${subredditName}`); - - res.json({ - type: "subreddit", - subreddit: subredditName, - }); - } catch (error) { - console.error(`API Subreddit Error for post ${postId}:`, error); - let errorMessage = "Unknown error during subreddit retrieval"; - if (error instanceof Error) { - errorMessage = `Subreddit retrieval failed: ${error.message}`; - } - res.status(400).json({ status: "error", message: errorMessage }); - } - } - ); - - app.use(router); - - const server = createServer(app); - server.on("error", (err) => console.error(`server error; ${err.stack}`)); - server.listen(getServerPort()); - ``` - - - diff --git a/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md b/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md index d43dc440..1954acb5 100644 --- a/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md +++ b/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md @@ -1,17 +1,17 @@ -# Setting up view modes and entry points +# View Modes & Entry Points ## View modes Devvit apps support two view modes: -**Inline Mode** +**Inline mode** - **What it is**: Your app loads directly within the post unit - **User experience**: Users see your app content immediately without clicking - **Use case**: Preview screens, game menus, leaderboards, or any content that works well in a post-sized container - **Requirements**: Only respond to taps and clicks, load quickly, and respect post boundaries -**Expanded Mode** +**Expanded mode** - **What it is**: Your app displays in a larger modal (web) or full screen (mobile) - **User experience**: Users click to enter a dedicated experience @@ -83,16 +83,16 @@ The `dir` property specifies where your built client files are located. With the Use the `entry` parameter when creating posts to specify which entry point from your `devvit.json` configuration to use. The entry value must match one of the keys defined in `post.entrypoints`. ```tsx title="server/index.ts" -import { reddit } from '@devvit/web/server'; +import { reddit } from "@devvit/web/server"; // Create a post using the default entrypoint async function createDefaultPost(context: any) { return await reddit.submitCustomPost({ subredditName: context.subredditName!, - title: 'Adventure Game', - entry: 'default', + title: "Adventure Game", + entry: "default", postData: { - gameState: 'menu', + gameState: "menu", }, }); } @@ -101,10 +101,10 @@ async function createDefaultPost(context: any) { async function createGamePost(context: any) { return await reddit.submitCustomPost({ subredditName: context.subredditName!, - title: 'Adventure Game', - entry: 'game', // Must match a key in devvit.json entrypoints + title: "Adventure Game", + entry: "game", // Must match a key in devvit.json entrypoints postData: { - gameState: 'active', + gameState: "active", initialized: true, }, }); @@ -123,14 +123,14 @@ async function createGamePost(context: any) { You can transition from inline mode to expanded mode with a different entry point, like this: ```tsx -import { requestExpandedMode } from '@devvit/web/client'; +import { requestExpandedMode } from "@devvit/web/client"; // Switch to the 'game' entrypoint in expanded mode const handleStartGame = async (event: React.MouseEvent) => { try { - await requestExpandedMode(event.nativeEvent, 'game'); + await requestExpandedMode(event.nativeEvent, "game"); } catch (error) { - console.error('Failed to enter expanded mode:', error); + console.error("Failed to enter expanded mode:", error); } }; ``` diff --git a/docs/capabilities/server/post-data.mdx b/docs/capabilities/server/post-data.mdx index dfcc117c..b94f5ebb 100644 --- a/docs/capabilities/server/post-data.mdx +++ b/docs/capabilities/server/post-data.mdx @@ -1,8 +1,7 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Post data +# Post Data You can attach small amounts of data (2KB) to a post when creating it and update this data using the `postData` capability. This enables dynamic, stateful experiences available on posts without a server call. Post data is scoped to the post, not users. @@ -15,6 +14,7 @@ Post data is sent to the client. Never store secrets or sensitive information. ::: ## Creating posts with data + When creating a post, include the `postData` parameter with your custom data object. ```ts title="server/index.ts" -import { context, reddit } from '@devvit/web/server'; -import type { JsonObject } from '@devvit/web/shared'; +import { context, reddit } from "@devvit/web/server"; +import type { JsonObject } from "@devvit/web/shared"; type CreatePostResponse = { postId: string; @@ -40,17 +40,17 @@ type ErrorResponse = { error: string; }; -app.post('/api/create-post', async (c) => { +app.post("/api/create-post", async (c) => { const { subredditName } = context; if (!subredditName) { - return c.json({ error: 'Subreddit name is required' }, 400); + return c.json({ error: "Subreddit name is required" }, 400); } const postData: JsonObject = { challengeNumber: 42, totalGuesses: 0, - gameState: 'active', + gameState: "active", pixels: [ [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], @@ -62,20 +62,20 @@ app.post('/api/create-post', async (c) => { [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], ], }; const post = await reddit.submitCustomPost({ subredditName, - title: 'Post with custom data', - entry: 'default', + title: "Post with custom data", + entry: "default", postData, }); return c.json({ postId: post.id, - message: 'Post created successfully', + message: "Post created successfully", }); }); ``` @@ -84,8 +84,8 @@ app.post('/api/create-post', async (c) => { ```ts title="server/index.ts" -import { context, reddit } from '@devvit/web/server'; -import type { JsonObject } from '@devvit/web/shared'; +import { context, reddit } from "@devvit/web/server"; +import type { JsonObject } from "@devvit/web/shared"; type CreatePostResponse = { postId: string; @@ -97,20 +97,20 @@ type ErrorResponse = { }; router.post( - '/api/create-post', + "/api/create-post", async (_req, res) => { const { subredditName } = context; if (!subredditName) { return res.status(400).json({ - error: 'Subreddit name is required' + error: "Subreddit name is required", }); } const postData: JsonObject = { challengeNumber: 42, totalGuesses: 0, - gameState: 'active', + gameState: "active", pixels: [ [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], @@ -122,20 +122,20 @@ router.post( [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], ], }; const post = await reddit.submitCustomPost({ subredditName, - title: 'Post with custom data', - entry: 'default', + title: "Post with custom data", + entry: "default", postData, }); res.json({ postId: post.id, - message: 'Post created successfully' + message: "Post created successfully", }); }, ); @@ -145,6 +145,7 @@ router.post( ## Updating post data + To update post data after creation, fetch the post and use the `setPostData()` method. ```ts title="server/index.ts" -import { context, reddit } from '@devvit/web/server'; -import type { JsonObject } from '@devvit/web/shared'; +import { context, reddit } from "@devvit/web/server"; +import type { JsonObject } from "@devvit/web/shared"; type UpdatePostDataRequest = { favoriteColor?: string; @@ -175,12 +176,12 @@ type ErrorResponse = { error: string; }; -app.post('/api/update-post-data', async (c) => { +app.post("/api/update-post-data", async (c) => { const { postId } = context; const { favoriteColor, username } = await c.req.json(); if (!postId) { - return c.json({ error: 'Post ID is required' }, 400); + return c.json({ error: "Post ID is required" }, 400); } try { @@ -191,18 +192,18 @@ app.post('/api/update-post-data', async (c) => { await post.setPostData({ ...currentData, - favoriteColor: favoriteColor || 'unknown', - lastUpdatedBy: username || 'anonymous', + favoriteColor: favoriteColor || "unknown", + lastUpdatedBy: username || "anonymous", lastUpdatedAt: new Date().toISOString(), }); return c.json({ success: true, - message: 'Post data updated successfully', + message: "Post data updated successfully", }); } catch (error) { - console.error('Error updating post data:', error); - return c.json({ error: 'Failed to update post data' }, 500); + console.error("Error updating post data:", error); + return c.json({ error: "Failed to update post data" }, 500); } }); ``` @@ -211,8 +212,8 @@ app.post('/api/update-post-data', async (c) => { ```ts title="server/index.ts" -import { context, reddit } from '@devvit/web/server'; -import type { JsonObject } from '@devvit/web/shared'; +import { context, reddit } from "@devvit/web/server"; +import type { JsonObject } from "@devvit/web/shared"; type UpdatePostDataRequest = { favoriteColor?: string; @@ -228,43 +229,45 @@ type ErrorResponse = { error: string; }; -router.post( - '/api/update-post-data', - async (req, res) => { - const { postId } = context; - const { favoriteColor, username } = req.body; +router.post< + string, + never, + UpdatePostDataResponse | ErrorResponse, + UpdatePostDataRequest +>("/api/update-post-data", async (req, res) => { + const { postId } = context; + const { favoriteColor, username } = req.body; - if (!postId) { - return res.status(400).json({ - error: 'Post ID is required' - }); - } + if (!postId) { + return res.status(400).json({ + error: "Post ID is required", + }); + } - try { - const post = await reddit.getPostById(postId); + try { + const post = await reddit.getPostById(postId); - // Get existing post data to merge with updates - const currentData = (context.postData || {}) as JsonObject; + // Get existing post data to merge with updates + const currentData = (context.postData || {}) as JsonObject; - await post.setPostData({ - ...currentData, - favoriteColor: favoriteColor || 'unknown', - lastUpdatedBy: username || 'anonymous', - lastUpdatedAt: new Date().toISOString(), - }); + await post.setPostData({ + ...currentData, + favoriteColor: favoriteColor || "unknown", + lastUpdatedBy: username || "anonymous", + lastUpdatedAt: new Date().toISOString(), + }); - res.json({ - success: true, - message: 'Post data updated successfully' - }); - } catch (error) { - console.error('Error updating post data:', error); - res.status(500).json({ - error: 'Failed to update post data' - }); - } - }, -); + res.json({ + success: true, + message: "Post data updated successfully", + }); + } catch (error) { + console.error("Error updating post data:", error); + res.status(500).json({ + error: "Failed to update post data", + }); + } +}); ``` @@ -275,23 +278,26 @@ router.post { return (
-
Post Data:
-
{JSON.stringify(context.postData, null, 2) ?? 'undefined'}
+
Post Data:
+
{JSON.stringify(context.postData, null, 2) ?? "undefined"}
); -} +}; ``` ## Limitations + Post data supports: + - JSON-serializable objects only - Maximum size of 2KB - Data persists with the post lifecycle (deleted when post is deleted) diff --git a/docs/capabilities/server/settings-and-secrets.mdx b/docs/capabilities/server/settings-and-secrets.mdx index 818fc1a6..47118e7f 100644 --- a/docs/capabilities/server/settings-and-secrets.mdx +++ b/docs/capabilities/server/settings-and-secrets.mdx @@ -1,11 +1,12 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; -# Settings and Secrets +# Settings & Secrets Configure your app with settings that can be customized per subreddit or globally across all installations. Settings allow moderators to customize app behavior for their subreddit, while secrets enable secure storage of sensitive data like API keys. Settings come in two scopes: + - **Subreddit settings**: Configurable by moderators for each installation - **Global settings & Secrets**: Set by developers and shared across all installations @@ -137,29 +138,29 @@ Settings can be retrieved from within your app. ```tsx title="server/index.ts" -import { settings } from '@devvit/web/server'; +import { settings } from "@devvit/web/server"; type ProcessResponse = { success: true }; // Get a single setting -const apiKey = await settings.get('apiKey'); +const apiKey = await settings.get("apiKey"); // Get multiple settings const [welcomeMessage, features] = await Promise.all([ - settings.get('welcomeMessage'), - settings.get('enabledFeatures') + settings.get("welcomeMessage"), + settings.get("enabledFeatures"), ]); // Use in an endpoint -app.post('/api/process', async (c) => { - const apiKey = await settings.get('apiKey'); - const environment = await settings.get('environment'); +app.post("/api/process", async (c) => { + const apiKey = await settings.get("apiKey"); + const environment = await settings.get("environment"); - const response = await fetch('https://api.example.com/endpoint', { + const response = await fetch("https://api.example.com/endpoint", { headers: { - 'Authorization': `Bearer ${apiKey}`, - 'X-Environment': environment - } + Authorization: `Bearer ${apiKey}`, + "X-Environment": environment, + }, }); return c.json({ success: true }); @@ -170,33 +171,36 @@ app.post('/api/process', async (c) => { ```tsx title="server/index.ts" -import { settings } from '@devvit/web/server'; +import { settings } from "@devvit/web/server"; type ProcessResponse = { success: true }; // Get a single setting -const apiKey = await settings.get('apiKey'); +const apiKey = await settings.get("apiKey"); // Get multiple settings const [welcomeMessage, features] = await Promise.all([ - settings.get('welcomeMessage'), - settings.get('enabledFeatures') + settings.get("welcomeMessage"), + settings.get("enabledFeatures"), ]); // Use in an endpoint -router.post('/api/process', async (req, res) => { - const apiKey = await settings.get('apiKey'); - const environment = await settings.get('environment'); - - const response = await fetch('https://api.example.com/endpoint', { - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'X-Environment': environment - } - }); +router.post( + "/api/process", + async (req, res) => { + const apiKey = await settings.get("apiKey"); + const environment = await settings.get("environment"); + + const response = await fetch("https://api.example.com/endpoint", { + headers: { + Authorization: `Bearer ${apiKey}`, + "X-Environment": environment, + }, + }); - res.json({ success: true }); -}); + res.json({ success: true }); + }, +); ``` @@ -232,22 +236,25 @@ Validate user input to ensure it meets your requirements before saving. Define a ```tsx title="server/index.ts" -import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; +import type { + SettingsValidationRequest, + SettingsValidationResponse, +} from "@devvit/web/shared"; -app.post('/internal/settings/validate-age', async (c) => { +app.post("/internal/settings/validate-age", async (c) => { const { value } = await c.req.json>(); if (!value || value < 0) { return c.json({ success: false, - error: 'Age must be a positive number', + error: "Age must be a positive number", }); } if (value > 365) { return c.json({ success: false, - error: 'Maximum age is 365 days', + error: "Maximum age is 365 days", }); } @@ -259,32 +266,37 @@ app.post('/internal/settings/validate-age', async (c) => { ```tsx title="server/index.ts" -import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; - -router.post>( - '/internal/settings/validate-age', - async (req, res): Promise => { - const { value } = req.body; - - if (!value || value < 0) { - res.json({ - success: false, - error: 'Age must be a positive number', - }); - return; - } +import type { + SettingsValidationRequest, + SettingsValidationResponse, +} from "@devvit/web/shared"; + +router.post< + string, + never, + SettingsValidationResponse, + SettingsValidationRequest +>("/internal/settings/validate-age", async (req, res): Promise => { + const { value } = req.body; - if (value > 365) { - res.json({ - success: false, - error: 'Maximum age is 365 days', - }); - return; - } + if (!value || value < 0) { + res.json({ + success: false, + error: "Age must be a positive number", + }); + return; + } - res.json({ success: true }); + if (value > 365) { + res.json({ + success: false, + error: "Maximum age is 365 days", + }); + return; } -); + + res.json({ success: true }); +}); ``` @@ -345,25 +357,25 @@ Here's a complete example showing both secrets and subreddit settings in action: ```tsx title="server/index.ts" -import type { JsonObject, JsonValue } from '@devvit/web/shared'; -import { settings } from '@devvit/web/server'; +import type { JsonObject, JsonValue } from "@devvit/web/shared"; +import { settings } from "@devvit/web/server"; type GenerateRequest = { messages: JsonValue }; type GenerateResponse = JsonObject; -app.post('/api/generate', async (c) => { +app.post("/api/generate", async (c) => { const [apiKey, model, maxTokens] = await Promise.all([ - settings.get('openaiApiKey'), - settings.get('aiModel'), - settings.get('maxTokens') + settings.get("openaiApiKey"), + settings.get("aiModel"), + settings.get("maxTokens"), ]); const { messages } = await c.req.json(); - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, @@ -381,35 +393,38 @@ app.post('/api/generate', async (c) => { ```tsx title="server/index.ts" -import type { JsonObject, JsonValue } from '@devvit/web/shared'; -import { settings } from '@devvit/web/server'; +import type { JsonObject, JsonValue } from "@devvit/web/shared"; +import { settings } from "@devvit/web/server"; type GenerateRequest = { messages: JsonValue }; type GenerateResponse = JsonObject; -router.post('/api/generate', async (req, res) => { - const [apiKey, model, maxTokens] = await Promise.all([ - settings.get('openaiApiKey'), - settings.get('aiModel'), - settings.get('maxTokens') - ]); - - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - max_tokens: maxTokens, - messages: req.body.messages, - }), - }); +router.post( + "/api/generate", + async (req, res) => { + const [apiKey, model, maxTokens] = await Promise.all([ + settings.get("openaiApiKey"), + settings.get("aiModel"), + settings.get("maxTokens"), + ]); + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages: req.body.messages, + }), + }); - const data = (await response.json()) as GenerateResponse; - res.json(data); -}); + const data = (await response.json()) as GenerateResponse; + res.json(data); + }, +); ``` diff --git a/docs/changelog.md b/docs/changelog.md index da594556..568d2f7d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,410 +9,73 @@ To use the latest version of Devvit: **Please note**: you may see features available across Devvit packages that are not documented or noted in our changelog. These are experimental features that are not stable and are subject to change, or removal, from the platform. Please use caution when testing or implementing experimental features. -## Release 0.12.24: App Profile Icons +## Release 0.13.0: Logged Out Users, Push Notifications, App Telemetry, and More! -**Release Date: May 18, 2026** +**Release Date: May 26, 2026** -You can now upload an [app icon](./guides/faq.mdx#AppFeatures) that appears both in Dev Portal and on the app’s Reddit profile, once your app has been approved and published. This helps your app maintain a consistent identity across surfaces. - -Other improvements in this release include: -- **Post URL handling**: improved post URL resolution for media posts (e.g. image and video posts), where the url field may point directly to the asset. If the url pathname differs from the post permalink, the canonical post URL is now resolved as `reddit.com + permalink` to handle edge cases. Otherwise, the existing url field continues to be used. -- **Playtest command stability improvements**, including: - - Waiting for `scripts.dev` to produce its first complete output before continuing - - Always uploading a fresh playtest build on startup - - Running `scripts.dev` as a killable process group for more reliable cleanup and shutdown behavior - - -## Release 0.12.23 - -**Release Date: May 11, 2026** - -This release doesn't present any developer-facing changes, and just includes stability, performance and code quality improvements for internal Devvit tools and packages. - -## Release 0.12.22: Profile Settings and Community Contributions - -**Release Date: May 1, 2026** - -This release adds support for editing your [app’s profile settings](./guides/faq.mdx#AppFeatures). You can now update your display name, public description, and NSFW flag directly from the **Developer Settings** tab, then save your changes in the Developer Portal to update the profile. - -**Bug Fixes** - -- Fixed a bug causing apps to throw `undefined undefined: undefined error` when making plugin calls. - -**Community Updates** -Shoutout to **fsvreddit** for these udpates: - -- `User.nsfw` now correctly reports whether the user profile is NSFW. -- `User.showNsfw` reports whether the user is over 18 and wishes to see NSFW content. -- `User.hasRedditPremium` reports whether the user is enrolled in Reddit Premium/Reddit Gold. - -## Release 0.12.21: Stability and Performance Improvements for Portal - -**Release Date: Apr 27, 2026** - -In this release, Portal now retrieves data directly from reliable backend services and no longer depends on GraphQL. This means: - -- Faster responses — average request time reduced by 40% (from 364ms → 216ms) -- Fewer errors — 62% reduction in server errors -- Improved reliability — Portal is no longer affected by GraphQL outages - -Also added: a `Filter()` method to the Reddit API (similar to AutoMod) that removes content from public view, sends it to ModQueue/Removed, and logs the action in ModLog. - -## Release 0.12.20: Maintenance Release - -**Release Date: Apr 20, 2026** - -This release contains a few internal fixes and improvements only. - -## Release 0.12.19: Minor Updates - -**Release Date: Apr 13, 2026** - -This release includes the following updates: - -- Clarified that when `requestExpandedMode()` navigates to the same entrypoint, it may (but not always) trigger a reload. - -- Added a `post` parameter to the `shareSheet` effect to allow sharing links to other posts. - -## Release 0.12.18: Custom Post Styling, Video Comments, and Cache - -**Release Date: Apr 6, 2026** - -This release adds support for post styles, which lets you customize how your app posts look within Reddit. We’ve added [Creating a custom post](./capabilities/creating_custom_post.md) documentation to walk you through building custom posts and configuring post styles. This includes options for setting background colors before your app loads, adjusting post height, and enabling custom share images when your app is shared. - -**Other fixes** - -- Added `video` to the `CommentMediaTypes` type. This fixes an issue where apps on subreddits with video comments crashed due to `getCurrentSubreddit()` throwing `invalid comment media type: video` error. -- Fixed an issue where in-memory cache data was unintentionally shared across subreddits. Cache is now partitioned per subreddit, which ensures you’ll get accurate data for each subreddit. - -## Release 0.12.17 Redis Update and Minor Fixes - -**Release Date: Mar 30, 2026** - -This release included updated Redis transaction support. `hSetNX` was available on `RedisClient` but missing from transaction interfaces, preventing atomic hash set-if-not-exists operations within transactions. Updates include: - -- Added `hSetNX` to the `TxClientLike` type -- Implemented `hSetNX` in the Redis transaction class, following the existing `hSet` pattern -- Added `hSetNX` and lowercase `hsetnx` alias to the public API transaction class, consistent with other hash method aliases - -**Other Fixes** - -- Fixed an issue where editing a post or comment with an image in the rich text builder triggered an `INVALID_SELFPOST: richtext_json` error. - -- Fixed an issue where SVG files containing XML declarations or `DOCTYPE` tags produced broken icons in `devvit create icons` by stripping content before the `` tag. - -## Release 0.12.16 Subreddit APIs and Poll Post Updates - -**Release Date: Mar 23, 2026** - -This release adds new APIs for smoother subreddit management, a unified rules model, and shared proto endpoints. Also included: new poll post functionality. - -**Subreddit Management** - -- `subreddit.updateSettings()` updates subreddit settings (this method only supports the settings currently exposed in the `SubredditSettings` type). -- `subreddit.updateRemovalReasons()` updates subreddit removal reasons. -- `subreddit.deleteRemovalReasons()` deletes a subreddit removal reason. - -**Subreddit Rules** - -- `subreddit.getRules()` (or `reddit.getRules()`) retrieves rules. -- `subreddit.createRule()` (or `reddit.createRule()`) creates rules. -- `subreddit.reorderRules()` (or `reddit.reorderRules()`) reorders rules. -- `rule.updateRule()` updates an existing rule. -- `rule.removeRule()` deletes a rule. - -Note: methods available on `reddit` provide the same functionality as their `subreddit` counterparts but do not require a subreddit instance. - -**Proto APIs** - -- `reddit.getBestPosts` returns a list of posts from the authenticated user’s front page. By default this runs as the app account, but it can be overridden on a per-app basis to always run as the user. -- `reddit.getDuplicatesForPosts` returns a list of other posts containing the same link as the input post. -- `reddit.showComment` unhides a comment that was hidden due to crowd control. Comments hidden for other reasons remain hidden. -- `comment.snoozeReports` and `post.snoozeReports` prevent reports for the input comment or post with the given reason from escalating to subreddit moderators for 7 days. -- `comment.unsnoozeReports` and `post.unsnoozeReports` remove the snooze applied by snoozeReports for the input comment or post. -- `comment.updateCrowdControlLevel` and `post.updateCrowdControlLevel` update the crowd control level for the comment or post (OFF, LENIENT, MEDIUM, or STRICT). -- `user.getTrophies` returns a list of trophies the user has earned. - -**Poll Post Enhancements** - -- Introduces a `pollOption` field on the `Post` object to access poll options. -- Adds a `getCurrentUserPollOption()` method to retrieve the option selected by the current user (if any). This method needs `runAs` permission to work, so please contact us if you intend to use it. - -## Devvit 0.12.15: The Nothing-To-See-Here Release - -**Release Date: Mar 16, 2026** - -This release contains internal improvements and infrastructure updates to improve stability and support future development. - -## Devvit 0.12.14: Community Game Pro Tips, Platform Changes, and Fixes - -**Release Date: Mar 9, 2026** - -This release brings new guidance to help you build more engaging community games, along with important platform updates and several developer experience improvements. - -**Level Up Your Community Games** - -The newly updated [Building Community Games](https://developers.reddit.com/docs/guides/best-practices/community_games#player-retention) guide includes new tips for creating engaging gameplay that thrives on Reddit! Learn which mechanics drive long-term engagement and how to improve your chances of [getting featured](https://developers.reddit.com/docs/guides/launch/feature-guide). - -**Other Fixes** - -- **Improved modmail validation and logging**: Added subreddit ID validation for Reddit Modmail requests and improved error logging. -- **Fixed image uploads in comments created with RichTextBuilder**: Resolved an issue where comment creation failed when adding images due to media processing conflicts. RichTextBuilder now accepts media URLs instead of media IDs, and the Devvit runtime converts them during processing to ensure compatibility with native post and comment media handling. -- **Custom Post styles support**: You can now pass an optional `styles` parameter to `submitCustomPost()`, and use the new `getPostStyles()` and `setPostStyles()` methods available on both the Reddit API and Post objects. - -## Devvit 0.12.13: Minor Tweaks - -**Release Date: Feb 17, 2026** - -This release has a few minor tweaks to make your life easier: - -- **CLI update**: By developer request, `publish` now bumps the patch version by default instead of the minor version. -- **Playtest fix**: Live reloading for apps during playtests is working again. -- **General clean-up**: We removed outdated templates that were previously used in CLI mode. - -## Devvit 0.12.12: New Templates, Vite Plugin, and Test Harness - -**Release Date: February 9, 2026** - -Release 0.12.12 is all about streamlining the developer experience. This release includes: - -- **Updated templates**. All templates now use the Vite plugin and a simplified structure, which includes: - - - Vite plugin support - - `agents.md` replacing `.kiro` and `.cursor` files - - Simpler dev workflow with clearer playtest logs - - No `.env` required for playtests - - Hono replaces Express as the default server (but you can still use any web framework you prefer!) - - Typed endpoints - - A bare template (formerly “hello world”) that now uses esbuild with no server framework - - New React + tRPC vibe coding template - -- **New Vite plugin**. This is an **optional plugin** that provides simpler console output, clearer logs, and unified build commands. The plugin hides the protobuf warning and automatically bundles entrypoints based on `devvit.json`, making multi-entrypoint apps easier to manage. - -- **Scripts field in devvit.json**. Enables you to provide a command to run during `devvit upload` and `devvit playtest`. You’ll notice a big difference in the level of noise your logs emit during playtest by using this instead of the `concurrently` script that templates previously used. - -- **Devvit test harness.** Adds an easy way to write integration tests for Devvit plugins using Vitest, supporting a more test-driven workflow. - -- **Standardized image upload limits**. GIF uploads are now all limited to 20 MB across all upload paths, aligning them with existing upload limits for a more consistent developer experience. - -## Devvit 0.12.11: App Review Update - -**Release Date: Feb 2, 2026** - -In this release, we’re cleaning up the app review process (literally). The CLI now uploads a clean, unbundled source zip (respecting .gitignore) for app review. This will help our human reviewers see properly formatted TypeScript and clearer diffs. - -## Devvit 0.12.10: Good Karma - -**Release Date: Jan 26, 2026** - -We've updated getUserKarmaForCurrentSubreddit() to allow users to fetch their own subreddit karma, even if they're not moderators. - -## Devvit 0.12.9: Gaming Templates and Error Handling - -**Release Date: Jan 20, 2026** - -In this release, we introduced a **Game Engines** tab on [developers.reddit.com/new](http://developers.reddit.com/new) to help you get started faster with gaming-specific templates. - -You’ll also see **improved error handling** in submitCustomPost() that correctly decodes and surfaces messages when the post data size limit is exceeded. - -## Devvit 0.12.8: Simplified Playtest Logs - -**Release Date: Jan 12, 2026** - -In our first release of the year, we present to you simplified playtest logs. The playtest command now produces cleaner, less verbose output by default. Detailed logs, including webview asset uploads, are now hidden behind the `--verbose flag` for easier reading. - -## Devvit 0.12.7: The REAL End-of-Year Updates - -**Release Date: Dec 22, 2025** - -It turns out that we couldn't end the year without a couple more upgrades: - -- Added an `authorFlair` field to `Post` and `Comment` objects in `@devvit/public-api` and `@devvit/reddit` (a community contribution from u/PitchforkAssistant). - -- Added `getUserKarmaFromCurrentSubreddit` to the public API, which returns a user's subreddit karma instead of their total Reddit karma. - -And now that's a wrap! - -## Devvit 0.12.6: End-of-the-Year Updates - -**Release Date: Dec 15, 2025** - -In the last release of 2025, we’ve made a slew of minor updates (they're still cool, though!). - -- **Added explicit version flag:** You can now specify an exact version number (e.g., `--version 1.2.3`) when publishing. -- **Deprecated `webViewModeListener`:** Now you can use the `"focus"` event on the inline view to reliably detect when control returns from the expanded view. -- **Fixed inconsistent casing:** The `Subreddit` type was previously printed in all lowercase for `getCurrentSubreddit()`, but in all uppercase for `getSubredditInfoByName()` and `getSubredditInfoById()`. This inconsistency has now been resolved. -- **Clarified non-functional fields:** The Payments plugin does not currently support filtering, so specifying `start` or `end` has no effect. This will be supported in a later release. -- **Added new User fields:** The `User` object now includes `displayName` and `about` to streamline user data experience. -- **Bug fixes** - - Corrected post height for Devvit Web apps to prevent layout jumps on the initial web view render. - - Fixed an issue with the `reddit.reorderWidgets` method. - - Resolved an issue where fetching image widgets without a linked URL would throw an error. +We’re very excited to introduce Release 0.13.0, which introduces new features to attract logged out users, drive user engagement, and provide telemetry data to your game. We also have some breaking changes, which are going to be really important if your app currently uses Blocks functionality. Read on… :::note -**2025 is a wrap!** All of us on the Dev Platform team wish you and yours the absolute best holiday season, and we can’t wait to create with you in 2026! +Upgrading to 0.13.0 is not mandatory, but you should be aware that Blocks UI support will be removed from all clients (web, Android, iOS) on June 30, 2026. ::: -## Devvit 0.12.5: Payments for Devvit Web - -**Release Date: Dec 1, 2025** - -In this release, we’re excited to bring payment support to Devvit Web. If you’re looking to add payments to your app, check out our [updated docs](./earn-money/payments/payments_overview.md). - -## Devvit 0.12.4: Ins and Outs - -**Release Date: Nov 24, 2025** +### Breaking Changes -Devvit 0.12.4 is packed with payments (experimental) polish, and new tooling for monitoring WebView traffic +_**Devvit Web**_ -**Devvit Web Payments (experimental) bugfixes and improvements** +If you use **Devvit Web** (`@devvit/web`), there’s only one breaking change: -- Fixed a bug with payments refunds hitting incorrect backend endpoint -- Fixed a bug where duplicate “Get Payments Help” menu items were showing -- The CLI’s `playtest` command watches your products file for live reloads, and `devvit products add` understands both legacy JSON files and the new config block so Devvit Web apps stay in sync. -- Payments types are re-exported from `@devvit/payments/shared`to `@devvit/web/shared`, preventing mismatched product/order typings downstream. +- The `splash` and `loading` screen support has been removed from `submitCustomPost()`. Please use a dedicated splash entrypoint HTML page instead as shown in the [project templates](../docs/examples/template-library.md). -**WebView analytics and APIs** +Old method: -- Improved accuracy of clicks measurement for App Directory Analytics -- Bundle size improvements -- Deprecated remaining splash screen APIs (`setSplash` and `SubmitCustomPostSplashOptions` fields) +```tsx +return await reddit.submitCustomPost({ + // Show platform splash screen inline and foo entrypoint in expanded mode. + splash: { + appDisplayName: "appDisplayName", + entry: "foo", + }, + title: "hello", +}); +``` -## Devvit 0.12.3: Odds and Ends +New method: -**Release Date: Nov 17, 2025** +```bash +return await reddit.submitCustomPost({ + // Show foo entrypoint inline. Change this to a splash entrypoint if wanted. + entry: 'foo' + title: 'hello', +}); +``` -This release focuses on Reddit data access and instrumenting WebView clients +And we did a little housekeeping: -**Reddit data & proto updates** +- Deprecated `inline` for post entrypoints in `devvit.json`. This property has no effect, and is always implied for post entrypoints. There are no built in splash screens, and any entrypoint may be opened in expanded mode. -- `@devvit/reddit` now exposes `getUserKarmaForSubreddit()` (later renamed to `getUserKarmaFromCurrentSubreddit()). -- `ModAction` trigger payloads now carries a stable `id` field for downstream tooling. +_**@devvit/public-api**_ -**Web client & realtime instrumentation** +If you use the old `@devvit/public-api`, **Blocks UI is no longer supported** in v0.13.0. These are the breaking changes: -- `@devvit/realtime` now publishes separate `client/` and `server/` entry points, preventing accidental server-only imports in browser bundles. -- Bundle size improvements -- Web clients now annotate the request `Context` with the user’s client name/version -- Improved accuracy of clicks measurement for App Directory Analytics +- Removed all custom post features from the Devvit singleton. This specifically includes `addCustomPostType()`, but also the ability to `submit()` custom posts and other Reddit API calls that operate on custom posts (`setPostData()`, `setCustomPostPreview()`, etc.). -**Payments status** + - Notably, menu actions and forms remain intact; apps can continue to provide interactivity through these mechanisms without porting to Devvit Web yet. (But this is deprecated, and support will be dropped in the future!). + - Removed Blocks support from `@devvit/payments`. The `usePayments()` hook was removed, and payments now only supports Devvit Web apps. + - Removed `realtime` and `useChannel` from the public-api. There is no UI to communicate with. -- `@devvit/payments` now tagged as `experimental` +- Removed `Devvit.Context`. You can import the context type from the public API package and should use that instead. +- Removed obsolete` @devvit/security` and `@devvit/pushnotif` packages. +- Remove obsolete key-value (`Context.kvStore`) plugin which had `List()` disabled for more than a year. Please use Redis directly. -## Devvit 0.12.2: Inline Mode, Launch Screens,Expanded App Experiences, and Developer Logs - -**Release Date: Nov 10, 2025** - -Release 0.12.2 delivers a major evolution in how interactive Devvit apps load, display, and engage users. With this update, you can now leverage inline web views, in addition to expanded mode, to build your interactive posts with Devvit Web. We’re also deprecating [Splash Screens](./capabilities/server/launch_screen_and_entry_points/splash_migration.mdx) in favor of more customizable HTML inline launch screens. - -**Inline Mode** - -Your app's web view can now load directly inside the post unit—right in the feed or on the post details page. Users can start interacting immediately, with no extra taps or page loads. - -Inline experiences blend smoothly into Reddit’s native post layout, which means that inline apps must meet performance standards and avoid conflicting with Reddit gestures for a native-quality experience. We encourage developers to read the guidance and rules around inline carefully before building with this feature. - -Check out [r/HotAndCold](https://www.reddit.com/r/HotAndCold/) and [r/Honk](https://www.reddit.com/r/honk/) for examples, and learn how to add [inline mode](./capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md#view-modes) to your app. - -:::note -Devvit apps using inline web views are currently seeing inflated metrics in their App Analytics Dashboard. We're working on improving these estimations. -::: +### New Features -**Improved Inline Launch Screens** +- **Building for Logged Out Users**. Reddit has an untapped resource for your apps: [logged out users](../docs/guides/logged-out-users.mdx). We’ve given you a guide to design your game so that it can be played and shared with anyone, and you can prompt logged out users to subscribe to your game. -Splash screens are yesterday’s news. The improved inline launch screens are now fully customizable, HTML-based entry points for your interactive posts. This update gives you control over design, animation, and loading behavior and uses the same tools and styles as the rest of your app. +- **Push Notifications (experimental)**. [Push notifications](../docs/capabilities/notifications/notifications-overview.md) help drive engagement, increase player retention, and build habit loops for players by bringing players back into your game at the right moments. We’ve also included detailed support for adding streaks to your game to encourage daily play! -The new first screen automatically loads before your app’s main entry point. Read the docs to learn how to [upgrade your app](./capabilities/server/launch_screen_and_entry_points/splash_migration.mdx) and [customize your launch screen](./capabilities/server/launch_screen_and_entry_points/launch_screen_customization.md). +- **Devvit Journeys (experimental)**. We’ve added a new telemetry feature that tracks the full lifecycle of a user session. [Devvit Journeys](../docs/capabilities/analytics/analytics-overview.md) gives you a new way to understand how players move through your game session from start to finish, making it easier to see where users engage, where they drop off, and which moments lead to completion. :::note -**Deprecation notice**: We're deprecating the splash parameter in submitCustomPost() and removing it in the next major version update. Learn how to [update your app](./capabilities/server/launch_screen_and_entry_points/splash_migration.mdx). +Experimental features are gated beta programs. Access to Push Notifications and Devvit Journeys is currently limited and requires approval before it can be functional in your app. ::: - -**Multiple App Entry Points** - -[Entry points](./capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md#multiple-entry-points) act as a router that organizes your app across different view modes. Each entry point specifies the initial HTML file for the specific context. A user might experience your app inline, when it’s embedded in a post, or launch it in expanded mode for a larger, full-screen mobile experience. - -**Expanded Mode** - -Expanded Mode lets users open your app or game in a full-screen experience, which is perfect for mobile devices. This feature works hand-in-hand with multiple entry points, letting users start small (interacting inline in the feed) and then expanding into a full experience. - -Learn how to add [Expanded Mode](./capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md#view-modes) functionality to your app. - -**Developer Logs** - -We’ve also shipped our first installation-level developer permissions. Developer logs read permission lets mods share read-only logs and install history of an installation with you. This is useful for debugging issues with a particular installation without having to be added as a mod to the subreddit. - -![Developer permissions](./assets/developer_permissions.png) - -We’re really excited about these updates and can’t wait to hear what you think! - -## Devvit 0.12.1: Cache Helper, Analytics dashboard for developers, and smaller fixes - -**Release Date: October 10, 2025** - -In this release, we’ve added back the cache helper for Devvit Web and also included an App Analytics tab for you to track your app’s engagement metrics. - -**Cache Helper** -The cache helper helps your app reduce the number of server side calls by caching the response for all users. This is great for any data that you plan to share across users, like a global leaderboard or consistent data from an external source like the score of a sports game. We now have this feature available in Devvit Web, and you can look up how to use it in the [cache helper docs](./capabilities/server/cache-helper.mdx). - -**App Analytics** -There’s a new App Analytics tab in your app settings that lets you track your progress against Reddit Developer Funds. - -![App Analytics](./assets/app_analytics.png) - -**Other fixes** -This release also includes a handful of other fixes including: - -- Added a method mergePostData() to append to postData. -- Fixed reddit.setPostFlair() method. -- Added a new triggers field that fixed an entrypoint triggers issue. -- Added error handling when trying to `devvit new`on an already existing app name. -- Added disconnectRealtime() and isRealtimeConnected() as helper methods for the realtime plugin. - -## Devvit 0.12.0: Devvit Web - -**Release Date: August 13, 2025** - -We're excited to introduce [Devvit Web](./capabilities/devvit-web/devvit_web_overview.mdx), a new way to build [games](./quickstart/quickstart.md) and [apps](./quickstart/quickstart-mod-tool.md) on Reddit using standard web technologies you already know and love. This release brings the power of modern web development to the Reddit platform, letting you build with React, Three.js, Phaser, and other industry-standard frameworks while maintaining access to all the Devvit capabilities you rely on. Moving forward, this will be the preferred way of building interactive post apps. - -**What's New** - -Devvit Web transforms how you build Reddit apps: - -- **Standard web development**: Build apps just like you would for the web, using familiar frameworks and tools -- **Server endpoints**: Define /api/ endpoints using Node.js frameworks like Express.js or Koa -- **New configuration system**: devvit.json provides a clean, declarative way to configure your app -- **Unified SDK**: @devvit/web package with clear client/server separation - Better AI compatibility: Standard web technologies work seamlessly with AI coding tools - -There's also a new [web-based creation flow](https://developers.reddit.com/new/) that makes creating new apps faster: - -- A step-by-step UI guides you through the initial steps to create an app -- Automatically builds a playtest subreddit for testing -- Gives you the code you need to access your new app via the terminal - -**Key Features** - -- **Client/server architecture**: Clear separation between frontend (@devvit/web/client) and backend (@devvit/web/server) -- **Full platform access**: Continued access to Redis, Reddit API, and Devvit's hosting services - **Current Limitations** - -- Serverless endpoints only (no long-running connections or streaming) -- Package restrictions (no fs or external native packages) -- Single request/response model (no websockets) -- Client-side fetch is limited to app domain (enforced via CSP) - -**Getting Started** - -- **New apps**: Go to developers.reddit.com/new to start building new apps - -**Support & Feedback** - -We'd love to hear about your experience with Devvit Web! Join the conversation in #devvit-web on Discord to share feedback, report issues, and connect with other developers building with Devvit Web. - -**Even More Features** - -In addition to Devvit Web, release 0.12 also adds: - -- **Post data** - [Post data](./capabilities/server/post-data.mdx) allows you to add data to your post when you submit it so that you can retrieve and use in your app without an additional Redis call. -- **Splash screen** - Having a compelling first screen of your app is one of the most important indicators of good post engagement. Every submitPost will come with a default per-post splash screen. diff --git a/docs/earn-money/payments/payments_add.mdx b/docs/earn-money/payments/payments_add.mdx index 8db52d08..e3b775c5 100644 --- a/docs/earn-money/payments/payments_add.mdx +++ b/docs/earn-money/payments/payments_add.mdx @@ -1,7 +1,7 @@ import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; -# Add payments +# Add Payments The Devvit payments API is available in Devvit Web. Keep reading to learn how to configure your products and accept payments. @@ -176,14 +176,14 @@ The JSON schema for the file format is available at
  • `INSTANT` for purchased items that are used immediately and disappear.
  • `DURABLE` for purchased items that are permanently applied to the account and can be used any number of times
  • `CONSUMABLE` for items that can be used at a later date but are removed once they are used.
  • `VALID_FOR_` values indicate a product can be used throughout a period of time after it is purchased.
  • | @@ -284,10 +284,16 @@ const GOD_MODE_SKU = "god_mode"; app.post("/internal/payments/fulfill", async (c) => { const order = await c.req.json(); if (!order.products.some((p) => p.sku === GOD_MODE_SKU)) { - return c.json({ success: false, reason: "Unable to fulfill order: sku not found" }); + return c.json({ + success: false, + reason: "Unable to fulfill order: sku not found", + }); } if (order.status !== "PAID") { - return c.json({ success: false, reason: "Becoming a god has a cost (in Reddit Gold)" }); + return c.json({ + success: false, + reason: "Becoming a god has a cost (in Reddit Gold)", + }); } const redisKey = `post:${order.postId}:user:${order.userId}:god_mode`; diff --git a/docs/earn-money/payments/payments_overview.md b/docs/earn-money/payments/payments_overview.md index 2b8b47af..c1c0bee8 100644 --- a/docs/earn-money/payments/payments_overview.md +++ b/docs/earn-money/payments/payments_overview.md @@ -1,4 +1,4 @@ -# In-App Purchases Overview +# Overview Add products to your app and get paid for what you sell. The payments plugin lets you prompt users to buy premium features that you build into your app, like in-game items, additional lives, or exclusive features into your app. diff --git a/docs/earn-money/payments/payments_publish.md b/docs/earn-money/payments/payments_publish.md index 3bea9c52..9b7ab0ca 100644 --- a/docs/earn-money/payments/payments_publish.md +++ b/docs/earn-money/payments/payments_publish.md @@ -1,4 +1,4 @@ -# Publish your app +# Publish Your App :::note The Developer Platform team reviews and approves apps and their products before products can be sold. diff --git a/docs/earn-money/payments/support_this_app.md b/docs/earn-money/payments/support_this_app.md index cc26a0a5..f1d66cd5 100644 --- a/docs/earn-money/payments/support_this_app.md +++ b/docs/earn-money/payments/support_this_app.md @@ -1,4 +1,4 @@ -# Support this app +# Support This App You can ask users to contribute to your app’s development by adding the “support this app” feature. This allows users to support your app with Reddit Gold in exchange for some kind of award or recognition. @@ -31,7 +31,10 @@ app.post("/internal/payments/fulfill", async (c) => { const order = await c.req.json(); const username = order.userId; // or the username field on the order if (!username) { - return c.json({ success: false, reason: "User not found" }); + return c.json({ + success: false, + reason: "User not found", + }); } const subredditName = order.subredditName ?? order.subredditId; diff --git a/docs/guides/logged-out-users.mdx b/docs/guides/logged-out-users.mdx new file mode 100644 index 00000000..58ec3338 --- /dev/null +++ b/docs/guides/logged-out-users.mdx @@ -0,0 +1,380 @@ +# Building for Logged Out Players + +Reddit has a large base of logged out users who arrive through SEO, shared links, or directly on reddit.com. Your game should support logged out users so that you can reach a larger audience, and you can create a path to convert those users into Reddit accounts who can subscribe to your game and come back to play more. + +This guide shows you how to design a Devvit game for logged-out traffic: + +- Make your game playable for logged out users: don't gate the core experience behind a login wall. +- Prompt users to log in at the right moment so they can subscribe to or follow your game. +- Optimize sharing so shared links and previews attract new players. +- Save the game state across the login boundary so logged out users who create an account can continue seamlessly after signing up. + +## Design for logged out users + +![Syllo Play Screen](../assets/analytics/syllo-play-2.png) + +### Create "just play" sessions + +Logged-out users: + +- Often arrive via search or shared links +- Lack context (no comments, subscriptions, or community cues) + +Recommended patterns: + +- Make the entry point obvious (e.g., a clear "Play" button) +- Provide in-game instructions (don't rely on external UI) +- Don't require login to start gameplay +- Reserve advanced features (saved progress, leaderboards, social) for logged-in users + +### Detect user state + +Distinguish between logged-in and logged-out users in your app logic. In Devvit, use the context: + +- `context.userId` is present only when the user is logged in +- `context.appSlug`, `context.postId`, etc. are available in both cases + +```ts +import type { Context } from "@devvit/public-api"; + +export async function onPlay(context: Context) { + const isLoggedIn = Boolean(context.userId); + + if (!isLoggedIn) { + // Logged-out experience: ie, no comments or "run as user" actions + // Keep the focus on playing the level. + } else { + // Enable richer features for logged-in players. + } +} +``` + +This pattern is especially important once you start using the login effect to gate certain actions (saving progress, sharing, etc.) behind a login flow. + +**Analytics and RDF** + +Your app's analytics dashboard distinguishes logged-in vs logged-out users. + +Note: logged out traffic does not count towards qualified engagement for Reddit Developer Funds. + +![Syllo Play Screen](../assets/analytics/engagement-analytics.png) + +## Prompt users to log in + +The biggest reason to convert a logged-out player into a logged-in user is retention: a logged-in user can subscribe to your subreddit, follow your game, and receive notifications that bring them back. A logged-out player who closes the tab is gone. + +Use `showLoginPrompt` to trigger Reddit's login/sign-up flow at a moment you choose. After the user creates or logs into their account, they return to your game. + +![Reddit Login Screen](../assets/analytics/login-screen.png) + +### When to prompt + +Trigger `showLoginPrompt()` during user actions where logging in unlocks something the player already wants, such as: + +- Subscribing to your game's subreddit (highest-leverage moment for retention) +- Following your game for updates or notifications +- Saving progress +- Sharing results +- Accessing social features (leaderboards, comments) + +### Best practices + +#### Only prompt logged-out users + +Check if the user is already logged in. If so, don't trigger the login effect. + +```ts +import type { Context } from "@devvit/public-api"; +import { showLoginPrompt } from "@devvit/client"; + +export async function onLoginButtonClick(context: Context) { + if (context.userId) { + return; + } + + showLoginPrompt(); +} +``` + +#### + +#### Trigger at natural breakpoints + +The login/sign-up flow reloads the page, so any in-memory game state will be lost unless you s[ave the logged-out game state](#save-the-logged-out-game-state). + +Recommendations: + +- Trigger `showLoginPrompt()` only at natural stopping points, e.g.: + - After a level is completed + - On a results or summary screen + - Before starting a new game +- Avoid prompting: + - Mid-puzzle or in the middle of an action that can't easily be resumed + - Repeatedly on every play; repeated prompts are likely to be ignored + +#### Pair the prompt with a clear value proposition + +Before triggering the prompt, show in-game messaging that tells the user _why_ they should log in. "Sign in to save game data and subscribe" converts better than a bare login dialog. + +:::note + +Your CTA must let the user know their game data will be saved when they subscribe. + +::: + +**Example: gating a "save progress" feature** + +```ts +import type { Context } from "@devvit/public-api"; +import { showLoginPrompt } from "@devvit/client"; + +export async function onSaveProgress(context: Context) { + const isLoggedIn = Boolean(context.userId); + + if (!isLoggedIn) { + // Optional: show in-game messaging before the prompt. + // e.g. render "Sign in to save your progress" in your own UI. + + showLoginPrompt(); + return; + } + + // User is logged in; proceed with your normal save logic. + await saveProgressForUser(context); +} +``` + +## Customize sharing to attract new players + +Shares are one of the main ways logged-out users arrive at your game. A well-customized share gives the recipient a clear reason to play and a clean landing experience, instead of a generic Reddit preview. + +There are three pieces: + +1. `showShareSheet` to trigger sharing from inside your app +2. **Deeplinks** to attach up to 1024 characters of data to a shared link +3. **Share previews** to set the image and text shown when links unfurl off-platform + +### Trigger sharing from your app + +Use `showShareSheet` to invoke Reddit's share behavior from your app. + +Example: display an invite banner + +![Syllo Sharesheet](../assets/analytics/syllo-sharesheet.png) + +```ts +import { showShareSheet } from "@devvit/web/client"; + +await showShareSheet({ + title: "Play today's puzzle", // optional title + text: "I solved the puzzle in 30s. Can you beat my time?", // optional body message + data: JSON.stringify({ + // optional share data (≤ 1024 chars) + type: "puzzle_challenge", + from: "userA", + message: "userA challenged you to solve this puzzle", + }), +}); +``` + +Parameters: + +- `data?: string` + - Arbitrary payload (invite code, JSON, etc.) + - Must be **≤ 1024 characters** + - Becomes the shared "user data" you can read when the link is opened +- `title?: string` + - Optional title for the share sheet or message +- `text?: string` + - Optional pre-filled message body +- `post?: T3` (on some platforms) + - Optional; if omitted, the current Devvit post is used + +You don't need to build your own share URLs for the standard "share this post" flow. `showShareSheet` handles that for you. + +### Share data and deeplinks + +Attach extra `data` to shared links and read it back when a recipient opens them using `getShareData()`. + +**Writing share data** + +Best practices: + +- Keep it short (≤ 1024 characters) +- Treat share data as untrusted input (users can tamper with links). Do **not** trust share data as an identity or authorization token +- Validate before using the payload + +**Reading share data when the page loads** + +```ts +import { getShareData } from "@devvit/web/client"; + +const raw = getShareData(); +const shared = raw ? JSON.parse(raw) : undefined; + +if (shared?.type === "puzzle_challenge") { + // Render challenge UI for the recipient. +} +``` + +### Customize share previews + +Set custom share images and text for each post instead of showing the default Reddit branding. This image is used for the thumbnail preview in compact subreddit feeds and for off-platform link unfurls. + +![Custom Share Image](../assets/analytics/syllo-logo.png) + +**Set share image when creating a post** + +```ts +// 1. Upload your image to Reddit via the media API. +import { media } from "@devvit/web/server"; + +async function uploadShareImageUrl(): Promise { + try { + const uploadResult = await media.upload({ + url: "https://example.com/share-preview.png", + type: "image", + }); + return uploadResult.mediaUrl; + } catch (error) { + console.error(`Failed to upload share image: ${error}`); + return undefined; + } +} + +// 2. Use the returned mediaUrl in submitCustomPost. +const shareImageUrl = await uploadShareImageUrl(); +const post = await reddit.submitCustomPost({ + title: "Daily Post", + subredditName: subreddit.name, + ...(shareImageUrl ? { shareImageUrl } : {}), +}); +``` + +**Update share image for an existing post** + +```ts +const post = await context.reddit.getPostById(context.postId); + +await post.setShareImageUrl("https://example.com/new-share-image.png"); +``` + +Notes: + +- Use a **static image URL** with appropriate resolution and aspect ratio +- The same image is used across major destinations (iMessage, WhatsApp, SMS, Slack, etc.) +- If you don't set a share image, Reddit uses a default app or post-level preview + +## Save the logged-out game state + +To make this a good experience for logged-out users who convert to logged-in users, you can save their game state across the login boundary. This example shows how to save a simplified completed game state (`score`) using the browser’s localStorage. + +:::note + +You should only collect and retain data strictly necessary for gameplay engagement and continuity. Game engagement data must not be used for advertising, profiling, personalization outside the gameplay experience, or any unrelated secondary purposes. + +::: + +Saving game state to localStorage comes with some limitations: localStorage resets whenever you install a new app version, and it does not persist across different browsers. + +### Set up localStorage helpers + +```javascript +type LoggedOutScore = { + postId: string; + score: number; +}; + +const loggedOutScoreKey = (postId: string) => `mygame:loggedOutScore:${postId}`; + +export function writeLoggedOutScoreToLocalStorage(value: LoggedOutScore): void { + try { + window.localStorage.setItem( + loggedOutScoreKey(value.postId), + JSON.stringify(value) + ); + } catch { + // Add error handling + } +} + +export function readLoggedOutScore(postId: string): LoggedOutScore | null { + try { + const raw = window.localStorage.getItem(loggedOutScoreKey(postId)); + if (!raw) return null; + + const parsed: unknown = JSON.parse(raw); + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as LoggedOutScore).postId !== "string" || + typeof (parsed as PendingScore).score !== "number" + ) { + return null; + } + + return parsed as LoggedOutScore; + } catch { + return null; + } +} + +export function clearLoggedOutScore(postId: string): void { + window.localStorage.removeItem(loggedOutScoreKey(postId)); +} +``` + +### Game ends: save the score from this logged-out game + +- If the user is logged in: send the data to Redis using `username` / `userId` from the session. +- If not logged in: `JSON.stringify` the score and `localStorage.setItem` under a key that includes postId (or some other unique identifier, ie `gameId`, depending on your app setup) so different posts don't get mixed up. + +```javascript +async function onGameEnd(postId: string, userLoggedIn: boolean, score: number) { + if (userLoggedIn) { + await saveScore(postId, score); + clearLoggedOutScore(postId); + } else { + writeLoggedOutScoreToLocalStorage(postId, score); + } +} + +async function saveScore( + input: { postId: string; score: number }, + context: { userId: string } +) { + const { userId } = context; + if (!userId ) throw new Error("userId required"); + if (!postId) throw new Error("postId required"); + + await redis.hSet(`scores:${input.postId}`, { + [userId]: String(input.score), + }); + + return { status: "ok" }; +} +``` + +### Migrate logged-out user scores to their userId when they log in + +- Next time the app runs and userId is present, save the score from localStorage to the user’s id in redis so that they get the credit for completing the game + +```javascript +async function migrateLoggedOutScoreOnAppInit(params: { + userId?: string; + postId?: string; +}): Promise { + const { userId, postId } = params; + if (!userId || !postId) return; + + const loggedOutScore = readLoggedOutScore(postId); + if (!loggedOutScore) return; + + await saveScore({ + postId: loggedOutScore.postId, + score: loggedOutScore.score, + }); + + clearLoggedOutScore(postId); +} +``` diff --git a/docs/guides/migrate/public-api.md b/docs/guides/migrate/public-api.md index 886d3259..2f9646cf 100644 --- a/docs/guides/migrate/public-api.md +++ b/docs/guides/migrate/public-api.md @@ -1,17 +1,18 @@ -# Migrating your PRAW App to Devvit Web +# Migrating Your PRAW App to Devvit Web -If you have built Reddit bots or moderation tools using PRAW (Python Reddit API Wrapper) and the standard Reddit API, you can port them directly into Reddit using Devvit Web. Devvit Web represents Reddit's modern client/server architecture for applications, allowing you to build rich moderation tools and automated bots utilizing familiar web frameworks (like Hono and Vite). -This guide shows you how to transition your Python/PRAW app to a Devvit Web app, utilizing concepts and logic structures you are already familiar with. +If you have built Reddit bots or moderation tools using PRAW (Python Reddit API Wrapper) and the standard Reddit API, you can port them directly into Reddit using Devvit Web. Devvit Web is Reddit's modern client/server architecture for applications, allowing you to build rich moderation tools and automated bots using familiar web frameworks (like Hono and Vite). -## **1\. Creating a Devvit App** +This guide shows you how to transition your Python/PRAW app to a Devvit Web app with concepts and logic structures you're already familiar with. -Unlike standard Python scripts, a Devvit Web application is structurally split into a front-end client and a back-end server, tied together by a configuration file. To jumpstart your migration, you can utilize official Reddit templates. +## Creating a Devvit app -### **Using the Mod Tool Template** +Unlike standard Python scripts, a Devvit Web app is structurally split into a front-end client and a back-end server and tied together by a configuration file. To jumpstart your migration, you can use official Devvit templates. -A highly recommended starting point for migrating PRAW moderation tools is the **Mod Tool Template**. Simply navigate to [developers.reddit.com/new](http://developers.reddit.com/new), select the Mod Tool Template and follow the instructions. The project created for you provides a complete foundation with a lightweight web framework (Hono) for backend logic, Vite for web components, and TypeScript for type safety. +### Mod tool template -### **The Architecture** +A highly recommended starting point for migrating PRAW moderation tools is the **Mod Tool Template**. Go to [developers.reddit.com/new](http://developers.reddit.com/new), select the Mod Tool Template, and follow the instructions. The project created for you provides a complete foundation with a lightweight web framework (Hono) for backend logic, Vite for web components, and TypeScript for type safety. + +### Architecture A typical Devvit Web template will generate the following file structure: @@ -19,7 +20,7 @@ A typical Devvit Web template will generate the following file structure: - **src/client/**: This directory holds your webview code (HTML/CSS/JS or React components built with Vite). For Mod Tools it's common to not use the client folder - **src/server/**: This directory contains your backend API logic. Here, a Node server framework (like Hono) processes requests, interacts with the Reddit API, and handles triggers. All server endpoints typically start with /internal/ or /api/. -## **2\. Python to TypeScript: Server Concepts** +## Python to TypeScript: Server Concepts In PRAW, you managed state in a continuous Python loop. In Devvit Web, your application acts as an API server responding to specific incoming webhook requests (handled seamlessly by Hono). Here are the key analogies: @@ -27,9 +28,10 @@ In PRAW, you managed state in a continuous Python loop. In Devvit Web, your appl - **pip install vs. npm install:** Instead of managing a requirements.txt file, Devvit uses a package.json file to track dependencies. - **Continuous Polling vs. Webhooks:** Instead of polling Reddit in a while True: loop, Devvit automatically sends a POST request to your Hono server whenever an event occurs. -## **3\. Triggers (Replacing Continuous Polling)** +## Triggers (replacing continuous polling) + +In Devvit Web, triggers are configured in your devvit.json. When an event happens (like a new comment), Devvit sends a payload to the designated endpoint on your server. -In Devvit Web, you configure Triggers in your devvit.json. When an event happens (like a new comment), Devvit sends a payload to the designated endpoint on your server. **Step 1: Configuration (devvit.json)** ```json @@ -45,45 +47,48 @@ In Devvit Web, you configure Triggers in your devvit.json. When an event happens ```ts // Hono is a small web framework used to define HTTP routes. -import { Hono } from 'hono'; +import { Hono } from "hono"; // TriggerResponse is the expected JSON response shape for trigger endpoints. -import type { TriggerResponse } from '@devvit/web/shared'; +import type { TriggerResponse } from "@devvit/web/shared"; // Create a web server app instance. const app = new Hono(); // Listen for the onCommentSubmit trigger endpoint configured in devvit.json. -app.post('/internal/triggers/on-comment-submit', async (c) => { +app.post("/internal/triggers/on-comment-submit", async (c) => { // Parse the incoming JSON body from Devvit. // The <...> part is a TypeScript type hint for what fields we expect. - const input = await c.req.json<{ author?: { username?: string; name?: string } }>(); + const input = await c.req.json<{ + author?: { username?: string; name?: string }; + }>(); // Pick a display name safely: // - ?. means "if this exists, read it" // - ?? means "if left side is null/undefined, use right side" - const authorName = input.author?.username ?? input.author?.name ?? 'unknown user'; + const authorName = + input.author?.username ?? input.author?.name ?? "unknown user"; console.log(`New comment created by ${authorName}!`); // Return a standard "ok" response with HTTP 200 status. - return c.json({ status: 'ok' }, 200); + return c.json({ status: "ok" }, 200); }); export default app; ``` -## **4\. Adding and Removing Comments** +## Adding and removing comments -To moderate content in Devvit Web, you use the Reddit API client accessible within your server logic. This behaves similarly to comment.mod.remove() in PRAW but relies on asynchronous function calls. +To moderate content in Devvit Web, use the Reddit API client accessible within your server logic. This behaves similarly to `comment.mod.remove()` in PRAW but relies on asynchronous function calls. ```ts // Hono handles incoming HTTP requests from Devvit. -import { Hono } from 'hono'; +import { Hono } from "hono"; // reddit is the Devvit Reddit API client for moderation/content actions. -import { reddit } from '@devvit/web/server'; +import { reddit } from "@devvit/web/server"; // TriggerResponse is the response type expected by trigger handlers. -import type { TriggerResponse } from '@devvit/web/shared'; +import type { TriggerResponse } from "@devvit/web/shared"; const app = new Hono(); -app.post('/internal/triggers/on-comment-submit', async (c) => { +app.post("/internal/triggers/on-comment-submit", async (c) => { // Parse request JSON and describe expected fields with a TypeScript type. const input = await c.req.json<{ author?: { id?: string }; @@ -92,13 +97,13 @@ app.post('/internal/triggers/on-comment-submit', async (c) => { // Get the comment ID if it exists. const commentId = input.comment?.id; // If we cannot find the comment ID, we cannot moderate the comment. - if (!commentId) return c.json({ status: 'ignored' }, 200); + if (!commentId) return c.json({ status: "ignored" }, 200); // Normalize text to lowercase so our keyword check is case-insensitive. - const body = input.comment?.body?.toLowerCase() ?? ''; + const body = input.comment?.body?.toLowerCase() ?? ""; // Check if the comment matches a specific moderation rule - if (body.includes('rule-breaking string')) { + if (body.includes("rule-breaking string")) { // 1. Remove the comment natively await reddit.remove(commentId, true); // true = flag as spam @@ -106,39 +111,39 @@ app.post('/internal/triggers/on-comment-submit', async (c) => { await reddit.submitComment({ // Reply to the removed comment itself. id: commentId, - text: 'Your comment was removed automatically for violating our community guidelines.', + text: "Your comment was removed automatically for violating our community guidelines.", // Run as the app account rather than a user account. - runAs: 'APP', + runAs: "APP", }); } - return c.json({ status: 'ok' }, 200); + return c.json({ status: "ok" }, 200); }); export default app; ``` -## **5\. Using Redis for Storage (Replacing SQLite/JSON)** +## Using Redis for storage (replacing SQLite/JSON) Instead of maintaining a local SQLite database for tracking user warnings or config states, Devvit Web gives you direct access to a managed Redis instance. ```ts // Hono handles HTTP routes. -import { Hono } from 'hono'; +import { Hono } from "hono"; // Redis client for key-value storage. -import { redis } from '@devvit/redis'; +import { redis } from "@devvit/redis"; // Standard trigger response type. -import type { TriggerResponse } from '@devvit/web/shared'; +import type { TriggerResponse } from "@devvit/web/shared"; const app = new Hono(); -app.post('/internal/triggers/on-post-submit', async (c) => { +app.post("/internal/triggers/on-post-submit", async (c) => { // Read trigger payload JSON. const input = await c.req.json<{ author?: { id?: string } }>(); // Extract the submitting user's ID. const authorId = input.author?.id; // If author is missing, skip this event safely. - if (!authorId) return c.json({ status: 'ignored' }, 200); + if (!authorId) return c.json({ status: "ignored" }, 200); // Build a per-user counter key, for example: post_count:t2_abc123 const redisKey = `post_count:${authorId}`; @@ -147,15 +152,16 @@ app.post('/internal/triggers/on-post-submit', async (c) => { const newCount = await redis.incrBy(redisKey, 1); console.log(`User ${authorId} has submitted ${newCount} posts.`); - return c.json({ status: 'ok' }, 200); + return c.json({ status: "ok" }, 200); }); export default app; ``` -## **6\. Using Schedulers (Replacing cron jobs or time.sleep)** +## Using schedulers (replacing cron jobs or time.sleep) + +PRAW bots frequently rely on time.sleep() for delayed tasks. In Devvit Web, you define Scheduled Tasks in devvit.json and map them to internal Hono endpoints. You can schedule recurring jobs (like cron) or one-off tasks. -PRAW bots frequently rely on time.sleep() for delayed tasks. In Devvit Web, you define Scheduled Tasks in devvit.json and map them to internal Hono endpoints. You can schedule recurring jobs (like cron) or one-off tasks. **Step 1: Configuration (devvit.json)** ```json @@ -168,25 +174,24 @@ PRAW bots frequently rely on time.sleep() for delayed tasks. In Devvit Web, you } } } - ``` -**Step 2: Scheduling and Handling (src/server/index.ts)** +**Step 2: Scheduling and handling (src/server/index.ts)** ```ts // Hono handles incoming webhook/scheduler HTTP requests. -import { Hono } from 'hono'; +import { Hono } from "hono"; // scheduler queues delayed jobs, reddit sends private messages. -import { scheduler, reddit } from '@devvit/web/server'; +import { scheduler, reddit } from "@devvit/web/server"; // Types for scheduler request/response payloads. -import type { TaskRequest, TaskResponse } from '@devvit/web/server'; +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; // Type for standard trigger responses. -import type { TriggerResponse } from '@devvit/web/shared'; +import type { TriggerResponse } from "@devvit/web/shared"; const app = new Hono(); // 1. Triggering the scheduled job (e.g., from a comment trigger) -app.post('/internal/triggers/on-comment-submit', async (c) => { +app.post("/internal/triggers/on-comment-submit", async (c) => { // Parse incoming trigger JSON. // This generic type describes what data shape we expect from the payload. const input = await c.req.json<{ @@ -194,13 +199,13 @@ app.post('/internal/triggers/on-comment-submit', async (c) => { comment?: { body?: string }; }>(); // Normalize body text so command checks are case-insensitive. - const body = input.comment?.body?.toLowerCase() ?? ''; + const body = input.comment?.body?.toLowerCase() ?? ""; - if (body.includes('!remindme')) { + if (body.includes("!remindme")) { // Use username when available, otherwise fall back to name. const username = input.author?.username ?? input.author?.name; // If we still do not have a recipient, skip this event. - if (!username) return c.json({ status: 'ignored' }, 200); + if (!username) return c.json({ status: "ignored" }, 200); // Create a timestamp one hour in the future. const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000); @@ -210,40 +215,42 @@ app.post('/internal/triggers/on-comment-submit', async (c) => { // A unique job ID (useful for debugging/canceling). id: `remind-user-${username}-${Date.now()}`, // Must match a task name declared in devvit.json. - name: 'remind-user-job', + name: "remind-user-job", // Custom payload delivered later to the scheduler endpoint. - data: { username, message: 'Your 1-hour reminder!' }, + data: { username, message: "Your 1-hour reminder!" }, // Time when this job should run. runAt: oneHourFromNow, }); } - return c.json({ status: 'ok' }, 200); + return c.json({ status: "ok" }, 200); }); // 2. The endpoint that executes when the timer concludes -app.post('/internal/scheduler/remind-user-job', async (c) => { +app.post("/internal/scheduler/remind-user-job", async (c) => { // Parse scheduler payload JSON. // TaskRequest<{ ... }> means "TaskRequest whose data looks like this object". - const req = await c.req.json>(); + const req = + await c.req.json>(); // Read values from req.data safely; default to empty object if data is missing. const { username, message } = req.data ?? {}; // Guard clause: ensure required fields exist before continuing. - if (!username || !message) return c.json({ status: 'ignored' }, 200); + if (!username || !message) + return c.json({ status: "ignored" }, 200); // Send a Reddit private message to the user. await reddit.sendPrivateMessage({ to: username, - subject: 'Automated Reminder', + subject: "Automated Reminder", text: message, }); - return c.json({ status: 'ok' }, 200); + return c.json({ status: "ok" }, 200); }); export default app; ``` -## **Summary of Concepts** +## Concept Summary | Concept | PRAW (Python) | Devvit Web (Hono \+ TypeScript) | | :------------------- | :-------------------------- | :-------------------------------------------------- | @@ -252,8 +259,6 @@ export default app; | Database Storage | SQLite, JSON, external DBs | import { redis } from '@devvit/redis' | | Delayed Actions | time.sleep() | scheduler.runJob() \+ Server Endpoint | -### --- - **References** 1. [Mod Tools Template - GitHub](https://github.com/reddit/devvit-template-mod-tool-devvit-web) diff --git a/sidebars.ts b/sidebars.ts index d381ccdb..a29fdebd 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -149,6 +149,11 @@ const sidebars: SidebarsConfig = { }, ], }, + { + type: "category", + label: "Analytics", + items: ["capabilities/analytics/analytics-overview", "capabilities/analytics/devvit-journeys"], + }, { type: "category", label: "Automation & Triggers", @@ -171,6 +176,14 @@ const sidebars: SidebarsConfig = { "earn-money/payments/support_this_app", ], }, + { + type: "category", + label: "Notifications", + items: [ + "capabilities/notifications/notifications-overview", + "capabilities/notifications/pn-best-practices", + "capabilities/notifications/adding-streaks"], + }, { type: "category", label: "Post Creation & Navigation", @@ -297,6 +310,11 @@ const sidebars: SidebarsConfig = { "guides/migrate/public-api", ], }, + { + type: "doc", + label: "Building for Logged Out Players", + id: "guides/logged-out-users", + }, { type: "doc", label: "Template Library",