Keeping users informed about the latest updates is crucial for any software product. A well-crafted changelog or "What's New" feature can make all the difference in improving user engagement and transparency. Whether you're fixing bugs, rolling out new features, or making performance improvements, a changelog ensures your users stay in the loop. In this post, we'll walk you through how to create a simple and effective "What's New" feature, using Increaser as an example. While Increaser's source code is private, you can find all the reusable code in the RadzionKit repository, so you can easily implement something similar in your own projects.
It's important to show both potential and existing users that your product is continuously evolving. On the website, updates can demonstrate to new visitors that your product is actively being developed, building trust right from the start. For existing users, displaying updates in the app helps them stay informed about new features, improvements, and changes. And by posting those updates on social media, you can attract new users and keep your audience engaged. To achieve this, we need a way to manage our updates in a single place, while allowing us to display them across different platforms in various formats.
Increaser's code is in a monorepo, so we will create a "changelog" package that will serve as a source of truth for all updates, and the core file here will be changelog.txt
. Since it's a user-facing application we don't really care about a concept of a version, it's just a date of a release and a list of changes. So our changelog file is incredibly simple. We use a timestamp to separate releases, and each update is separated by a new line, so it looks like this.
In Increaser's monorepo, we organize everything around a single source of truth for our updates by creating a dedicated "changelog" package. The core of this package is a simple file named changelog.txt
, where we log every user-facing update. Since we're dealing with a consumer application, there's no need to complicate things with versioning. Instead, we rely on the date of the release and a list of changes.
This approach keeps things minimalistic. Each release is separated by a timestamp, and individual updates are listed one by one, each on a new line. This simplicity allows us to manage updates efficiently while ensuring that all changes are easy to track and understand. Here's a basic example of how the changelog.txt
file might look:
Users can now edit and reorder habits right from the "Track" and "Stats" views on the "Habits" page.
User can reset a specific habit from the "Edit" view of a habit.
Every "Tasks" sub-page now has an "Add" button
Users can turn an existing task into a template and templates are now always available when creating or editing tasks.
It's more comfortable to set a weekly goal for a project
Added "Time tracking" and "Taks Management" articles to the "Info" page
1729225980085
Users connect goals to their habits by either adding new habits from the goal view or picking an existing habit.
To help users remember to track their habits, a prompt appears in the sidebar navigation next to the "Habits" item. This prompt will show up if the user hasn't visited the "Habits" page that day.
Added a line to the weekly project report chart to display budgeted time for selected projects or total budgeted time for all projects.
Updated the projects page to display not only the emoji and name, but also the budget, goal, and "Mon-Fri" label for workday projects.
User can set project weekly budget and goal while creating or editing a project.
"Report" subpage was moved from "Projects" page to a dedicated "Timesheet" page for easier access.
1728708465131
Users can now expedite the creation of recurring tasks ahead of schedule by using the 'Create Now' button found in the 'Upcoming' tab of the 'Tasks' page.
Users can now see on the 'Upcoming' tab of the 'Tasks' page when their recurring task will actually be created.
...
If our changelog.txt
file doesn't start with a timestamp, it means that those changes haven't been officially announced yet. This simple convention allows us to keep track of updates that are still in development or awaiting release. Once we’re ready to make the update public, we add a timestamp to signify the release date.
While having a simple text file is useful for logging updates, it’s not the most convenient format to work with in our TypeScript codebase, especially when we need to reuse this data in different contexts. To handle this more effectively, we introduce the ProductUpdate
type, which structures all the necessary information about each update.
type ProductUpdateItem = {
description: string
}
export const productUpdateSocials = [
"telegram",
"x",
"linkedIn",
"indieHackers",
"reddit",
"youtube",
] as const
export type ProductUpdateSocial = (typeof productUpdateSocials)[number]
export const productUpdateSocialName = {
telegram: "Telegram",
x: "X",
linkedIn: "LinkedIn",
indieHackers: "Indie Hackers",
reddit: "Reddit",
youtube: "YouTube",
} as const
export type ProductUpdateSocials = Partial<Record<ProductUpdateSocial, string>>
export type ProductUpdate = ProductUpdateSocials & {
releasedAt: number
name: string
description: string
items?: ProductUpdateItem[]
}
The ProductUpdate
type builds on the ProductUpdateSocials
type, which is essentially a record of URLs to the social media posts related to the update. This allows us to easily track and manage the various announcements across platforms like Telegram, LinkedIn, X, and more.
Following that, we have a releasedAt
field, which stores the timestamp of when the update went live. The name
field gives each update a title, while description
provides a summary or an overview of what the update is about.
Finally, the items
array consists of ProductUpdateItem
objects. Each item represents an individual change or feature introduced in the update, and for now, each item only includes a description
.
At some point, we'll need to store product updates in a database, but since we haven't made that many updates yet, a simple productUpdates.ts
file will suffice. This file will contain all the updates in a single array, making it easy to manage and reference in the codebase for now.
import { ProductUpdate } from './ProductUpdate'
export const productUpdates: ProductUpdate[] = [...]
Once we've collected enough items in the changelog.txt
file, it's time to start announcing them to our users. This process begins with the generate.ts
script.
import { createTsFile } from "@lib/codegen/utils/createTsFile"
import { injectProductUpdate } from "../utils/injectProductUpdate"
import {
productUpdatesFileDirectory,
productUpdatesFileName,
readProductUpdatesFile,
} from "../utils/productUpdatesFile"
import { createNewYoutubeFile } from "../utils/youtubeFile"
import { createNewYoutubeFolder } from "../utils/youtubeFolder"
const generate = () => {
const productUpdatesStr = readProductUpdatesFile()
const newProductUpdate = injectProductUpdate(productUpdatesStr, {
releasedAt: 0,
})
createTsFile({
directory: productUpdatesFileDirectory,
fileName: productUpdatesFileName,
content: newProductUpdate,
})
createNewYoutubeFile()
createNewYoutubeFolder()
}
generate()
First, we read the existing product updates from the productUpdates.ts
file using the readProductUpdatesFile
function.
import fs from "fs"
import path from "path"
export const productUpdatesFileDirectory = path.resolve(__dirname, "../")
export const productUpdatesFileName = "productUpdates"
const productUpdatesFilePath = path.resolve(
productUpdatesFileDirectory,
`${productUpdatesFileName}.ts`
)
export const readProductUpdatesFile = () =>
fs.readFileSync(productUpdatesFilePath, "utf8")
The injectProductUpdate
function is responsible for adding a new, empty product update to the existing list of updates. It takes the current file content as a string and a partial ProductUpdate
object, which initially only includes the releasedAt
field set to 0
.
import { ProductUpdate } from "../ProductUpdate"
export const injectProductUpdate = (
fileContent: string,
value: Partial<ProductUpdate>
) => {
const declaration = "export const productUpdates: ProductUpdate[] = ["
const [before, after] = fileContent.split(declaration)
const productUpdate: ProductUpdate = {
releasedAt: 0,
name: "",
description: "",
items: [],
youtube: "",
telegram: "",
x: "",
linkedIn: "",
indieHackers: "",
reddit: "",
...value,
}
return [before, declaration, `${JSON.stringify(productUpdate)},`, after].join(
"\n"
)
}
The function first splits the fileContent
string around the productUpdates
array declaration, which separates the part before the declaration and the part after. It then creates a ProductUpdate
object with default values and overrides any values provided in the value argument. Finally, the function inserts the new product update into the array by reconstructing the file content and returning it as a string. This ensures the update is properly injected into the productUpdates.ts
file while maintaining its structure.
With the new string containing the updated product updates, we can use the createTsFile
function from RadzionKit's codegen
package to write the changes back to the productUpdates.ts
file.
import { formatCode } from "./formatCode"
import { createFile } from "./createFile"
interface CreateTsFileParams {
extension?: "ts" | "tsx"
directory: string
fileName: string
generatedBy?: string
content: string
}
export const createTsFile = async ({
extension = "ts",
directory,
fileName,
generatedBy,
content,
}: CreateTsFileParams) => {
const code = await formatCode({
content: generatedBy
? [`// This file is generated by ${generatedBy}`, content].join("\n")
: content,
extension,
})
createFile({
directory,
fileName,
content: code,
extension,
})
}
Since we’ll also be making a YouTube video about the update, we can streamline this process by creating a new file specifically for the YouTube script. This script file will be stored in a youtube
folder inside our changelog package.
import fs from "fs"
import path from "path"
export const createNewYoutubeFile = () =>
fs.writeFileSync(path.resolve(__dirname, "../youtube/latest.md"), "", "utf8")
export const releaseNewYoutubeFile = (timestamp: number) =>
fs.renameSync(
path.resolve(__dirname, "../youtube/latest.md"),
path.resolve(__dirname, `../youtube/${timestamp}.md`)
)
Additionally, we’ll set up a separate YouTube
folder outside of the monorepo to contain all the assets for the video.
import fs from "fs"
import path from "path"
const youTubeFolders = path.resolve(__dirname, "../../../../changelog")
const latestDirectory = path.join(youTubeFolders, "latest")
export const createNewYoutubeFolder = () => fs.mkdirSync(latestDirectory)
export const releaseNewYoutubeFolder = (timestamp: number) =>
fs.renameSync(
latestDirectory,
path.join(youTubeFolders, timestamp.toString())
)
To streamline the process of creating YouTube video scripts for product updates, we’ve built a script that generates a prompt specifically for ChatGPT. While using the API is a viable option, I personally choose to use the ChatGPT interface since I’m already paying for a subscription—this helps avoid paying for API usage on top of that. However, if you're not paying for a subscription, you could easily adapt this process to use the API.
import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"
const youtube = () => {
const changelog = readChangelogFile()
const changelogItems = parseChangelog(changelog)
const { items } = changelogItems[0]
const prompt = [
"You will write a script for a YouTube video about new product updates.",
"It should be a short video, just a few minutes long, enough to make the user understand the value of the updates.",
"It should not be promotional, but sound human, as it is just a founder talking about what is new in the product.",
"The script should be plain text, free from any formatting, and be ready to put in a teleprompter.",
"The ending should be as short as possible, and should have a call to action to try it and give feedback.",
'Start with "Hey everyone, Radzion here." and launch into the updates as fast as possible.',
"Return the script as a markdown snippet.",
"Updates are ordered by their priority:",
items.map((item) => ` - ${item}`).join("\n"),
].join("\n")
console.log(prompt)
clipboardy.writeSync(prompt)
}
youtube()
First, we need to read the changelog file and transform it into a more structured format that’s easier to work with in our code. We define a ChangelogItem
type to represent each entry. This type contains two fields: releasedAt
, which can either be a timestamp or null for unreleased updates, and items, which is an array of strings representing the individual changes in that update.
export type ChangelogItem = {
releasedAt: number | null
items: string[]
}
The parseChangelog
function converts a raw changelog string into an array of ChangelogItem
objects. It splits the input string into lines and processes each line individually. If a line contains only digits, it is treated as a timestamp, marking the start of a new changelog entry. The current entry is then pushed into an array, and a new ChangelogItem
is created. Non-empty lines that aren't timestamps are treated as product updates and added to the current item. If no timestamp has been encountered yet, a new item with releasedAt
set to null
is created, indicating an unreleased update. At the end, the last changelog item is pushed to ensure all updates are captured.
import { ChangelogItem } from "../ChangelogItem"
export function parseChangelog(changelog: string): ChangelogItem[] {
const lines = changelog.split("\n")
const changelogItems: ChangelogItem[] = []
let currentChangelogItem: ChangelogItem | null = null
for (const line of lines) {
if (/^\d+$/.test(line.trim())) {
// Line is a timestamp
if (currentChangelogItem) {
changelogItems.push(currentChangelogItem)
}
currentChangelogItem = {
releasedAt: parseInt(line.trim(), 10),
items: [],
}
} else if (line.trim() !== "") {
// Line is not empty
if (!currentChangelogItem) {
// Create a new changelog item with releasedAt set to null
currentChangelogItem = {
releasedAt: null,
items: [],
}
}
currentChangelogItem.items.push(line)
}
}
// Push the last changelog item if exists
if (currentChangelogItem) {
changelogItems.push(currentChangelogItem)
}
return changelogItems
}
Once we've parsed the changelog, we can easily extract the unreleased changes and insert them into a prompt for ChatGPT. However, to ensure that ChatGPT understands what Increaser is all about, we rely on a context.md
file, which contains all the raw information about the app and its features. While this file may seem a bit lengthy, you only need to write it once and update it as the app evolves. By sending the content of this file before the prompt, ChatGPT has the context it needs to provide high-quality outputs that are tailored to the app's functionality and updates.
Once we receive the script from ChatGPT and refine it, we save it in the latest.md
file inside the youtube
folder. This file serves as the finalized script for the video. After that, we record the video and post it on YouTube. To save time, I don’t create a custom thumbnail for every video; instead, I simply update the date on the existing thumbnail to keep things quick and efficient.
With the video and thumbnail ready, the next step is to post the content on YouTube and other social media platforms. To streamline this, we use the announce.ts
script, which generates prompts tailored for each platform. This ensures that our announcements are formatted appropriately for YouTube, LinkedIn, Telegram, and other channels. As we post the updates, we simultaneously update the first item in the productUpdates.ts
file, filling in the URLs for each post.
import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"
import { ProductUpdateSocial, productUpdateSocialName } from "../ProductUpdate"
import { toEntries } from "@lib/utils/record/toEntries"
const socialPrompt: Record<ProductUpdateSocial, string[]> = {
telegram: [
"A message for Increaser telegram channel.",
`Include https://app.increaser.org url in the content.`,
],
x: [
"A post on X, 280 characters max. Do not use hashtags.",
`Include https://increaser.org url in the content.`,
],
reddit: [
`Include Increaser's website url in the content.`,
"Also provide a title for the post.",
`Include https://increaser.org url in the content.`,
],
indieHackers: [
`Include Increaser's app url in the content.`,
"Also provide a title for the post.",
`Include https://increaser.org url in the content.`,
],
linkedIn: [
`Do not use markdown syntax, e.g. no **bold** or bullet lists, LinkedIn posts do not support markdown.`,
`Include https://increaser.org url in the content.`,
],
youtube: ["A video title and description."],
}
const announce = () => {
const changelog = readChangelogFile()
const changelogItems = parseChangelog(changelog)
const { items } = changelogItems[0]
const prompt = [
[
"You will write an announcement for new product updates.",
"Use your judgment to make the announcement engaging and informative.",
"Make each announcement feel native to the platform.",
"Return copy for each platform in a separate markdown snippet",
'Do not use words "user" or "users".',
"Titles should represent an essence of the updates.",
].join(" "),
...toEntries(socialPrompt).map(({ key, value }) =>
[`### ${productUpdateSocialName[key]}`, value].join("\n")
),
[
"Updates are ordered by their priority: ",
...items.map((item, index) => `${index + 1}. ${item}`),
].join("\n"),
].join("\n\n")
console.log(prompt)
clipboardy.writeSync(prompt)
}
announce()
With the social media announcements completed, the final step is to announce the update on the website and in the app. To do this, we first need to complete the productUpdates.ts
file with the actual update details. This includes filling in the name
, description
, and items
fields for the latest update. We can achieve this by running the prepare.ts
script, which generates a ChatGPT prompt designed to help us craft the remaining fields.
import clipboardy from "clipboardy"
import { parseChangelog } from "../utils/parseChangelog"
import { readChangelogFile } from "../utils/changelogFile"
const getPrompt = (items: string[]) => {
if (items.length === 1) {
return [
"You will write an announcement for a new product update.",
'It will be displayed in the "What\'s new" section of the app and website.',
'Return it as a JavaScript object with the "name" and "description" string properties.',
"Keep the copy short, but make sure the user will understand each update and its value.",
"Description should be a plain text, emojis are allowed if you think it makes it more engaging.",
"The name should capture the essence of the update.",
'Return it as a JavaScript object with the "name" and "description" properties.',
`Product update: ${items[0]}`,
].join("\n")
}
return [
"You will write an announcement for new product updates.",
'It will be displayed in the "What\'s new" section of the app and website.',
'Return it as a JavaScript object with the "name" and "description" string properties and "items" property which is an array of objects with a "description" string property.',
"Keep the copy short, but make sure the user will understand each update and its value.",
"Updates are ordered by their priority.",
"The name should capture the essence of the update and should not be generic.",
"The description should summarize the updates.",
"Each item should correspond to a product update.",
"Product updates:",
items.map((item) => ` - ${item}`).join("\n"),
].join("\n")
}
const productUpdate = () => {
const changelog = readChangelogFile()
const changelogItems = parseChangelog(changelog)
const { items } = changelogItems[0]
const prompt = getPrompt(items)
console.log(prompt)
clipboardy.writeSync(prompt)
}
productUpdate()
Now, the final step is to fill in the releasedAt
field. For that, we use the release.ts
script. This script automatically sets the release date in the changelog.txt
file, updates the productUpdates.ts
file with the new releasedAt
value, and renames both the YouTube script file and its corresponding folder to reflect the release.
import { createTsFile } from "@lib/codegen/utils/createTsFile"
import { readChangelogFile, writeChangelogFile } from "../utils/changelogFile"
import { changelogItemsToString } from "../utils/changelogItemsToString"
import { parseChangelog } from "../utils/parseChangelog"
import {
productUpdatesFileDirectory,
productUpdatesFileName,
readProductUpdatesFile,
} from "../utils/productUpdatesFile"
import { releaseNewYoutubeFile } from "../utils/youtubeFile"
import { releaseNewYoutubeFolder } from "../utils/youtubeFolder"
const generate = () => {
const changelogStr = readChangelogFile()
const changelog = parseChangelog(changelogStr)
const { items } = changelog[0]
const releasedAt = Date.now()
const newChangelog = [
{
items,
releasedAt,
},
...changelog.slice(1),
]
const newChangelogStr = changelogItemsToString(newChangelog)
writeChangelogFile(newChangelogStr)
const productUpdatesStr = readProductUpdatesFile()
const newProductUpdatesStr = productUpdatesStr.replace(
/releasedAt:\s*\d+/,
`releasedAt: ${releasedAt}`
)
createTsFile({
directory: productUpdatesFileDirectory,
fileName: productUpdatesFileName,
content: newProductUpdatesStr,
})
releaseNewYoutubeFile(releasedAt)
releaseNewYoutubeFolder(releasedAt)
}
generate()
With the product updates finalized, we can deploy both the app and website, which share the ProductUpdatesList
component. This component pulls updates from the productUpdates.ts
file, orders them by the releasedAt
field in descending order, and displays each update using the ProductUpdateItem
component. Each update is separated by a line using SeparatedByLine
from RadzionKit for a clean and organized layout.
import { productUpdates } from "@increaser/changelog/productUpdates"
import { ProductUpdateItem } from "@increaser/ui/changelog/ProductUpdateItem"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { order } from "@lib/utils/array/order"
import styled from "styled-components"
const Container = styled(SeparatedByLine)`
max-width: 640px;
`
export const ProductUpdatesList = () => {
const items = order(productUpdates, (v) => v.releasedAt, "desc")
return (
<Container gap={40}>
{items.map((value, index) => (
<ProductUpdateItem key={index} value={value} />
))}
</Container>
)
}
In the first section of the ProductUpdateItem
component, we display the release date, update name, and description. The release date is formatted using the date-fns
library to show a user-friendly format. Both the update name and description are rendered using the Text
component from RadzionKit.
import { ComponentWithValueProps } from "@lib/ui/props"
import {
ProductUpdate,
productUpdateSocials,
} from "../../changelog/ProductUpdate"
import { HStack, VStack } from "@lib/ui/css/stack"
import { format } from "date-fns"
import { Text } from "@lib/ui/text"
import { NonEmptyOnly } from "@lib/ui/base/NonEmptyOnly"
import { withoutUndefinedFields } from "@lib/utils/record/withoutUndefinedFields"
import { toEntries } from "@lib/utils/record/toEntries"
import { SocialLink } from "@lib/ui/buttons/SocialLink"
import { Match } from "@lib/ui/base/Match"
import { TelegramIcon } from "@lib/ui/icons/TelegramIcon"
import { XIcon } from "@lib/ui/icons/XIcon"
import { LinkedinIcon } from "@lib/ui/icons/LinkedinIcon"
import { RedditIcon } from "@lib/ui/icons/RedditIcon"
import { IndieHackersIcon } from "@lib/ui/icons/IndieHackersIcon"
import { YouTubeColoredIcon } from "@lib/ui/icons/YouTubeColoredIcon"
import { recordFromKeys } from "@lib/utils/record/recordFromKeys"
import { ProductUpdateYouTubeVideo } from "./ProductUpdateYouTubeVideo"
import { ProductUpdateSubItem } from "./ProductUpdateSubItem"
export const ProductUpdateItem = ({
value,
}: ComponentWithValueProps<ProductUpdate>) => {
const socials = toEntries(
withoutUndefinedFields(
recordFromKeys(productUpdateSocials, (social) => value[social])
)
)
return (
<VStack gap={16}>
<VStack gap={4}>
<Text size={14} color="supporting">
{format(value.releasedAt, "MMMM d, yyyy")}
</Text>
<Text size={20} weight="500" color="contrast">
{value.name}
</Text>
<Text color="supporting" height="l">
{value.description}
</Text>
</VStack>
{value.youtube && <ProductUpdateYouTubeVideo value={value.youtube} />}
<NonEmptyOnly
value={socials}
render={(items) => (
<HStack alignItems="center" gap={8}>
<Text color="shy" weight="600">
Discuss on
</Text>
<HStack alignItems="center">
{items.map(({ key, value }) => {
return (
<SocialLink key={key} to={value}>
<Match
value={key}
telegram={() => <TelegramIcon />}
x={() => <XIcon />}
linkedIn={() => <LinkedinIcon />}
reddit={() => <RedditIcon />}
indieHackers={() => <IndieHackersIcon />}
youtube={() => <YouTubeColoredIcon />}
/>
</SocialLink>
)
})}
</HStack>
</HStack>
)}
/>
<NonEmptyOnly
value={value.items}
render={(items) => (
<VStack>
{items.map((value, index) => (
<ProductUpdateSubItem key={index} value={value} />
))}
</VStack>
)}
/>
</VStack>
)
}
If an update includes a YouTube video, the ProductUpdateYouTubeVideo
component embeds a YouTube player directly into the app, allowing users to play the video in place. The video only loads when it becomes visible on the screen, thanks to the IntersectionAware
component from RadzionKit, improving performance. The ElementSizeAware
component from RadzionKit ensures the player resizes responsively. The video is displayed in a styled container with borders and rounded corners, and the useBoolean
hook manages the play and pause state.
import { ComponentWithValueProps } from "@lib/ui/props"
import { VStack } from "@lib/ui/css/stack"
import styled from "styled-components"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { getColor } from "@lib/ui/theme/getters"
import { IntersectionAware } from "@lib/ui/base/IntersectionAware"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import YouTubePlayer from "react-player/lazy"
import { useBoolean } from "@lib/ui/hooks/useBoolean"
const youTubeVideoRatio = 9 / 16
const VideoWrapper = styled.div`
width: 100%;
height: auto;
${borderRadius.m}
overflow: hidden;
border: 2px solid ${getColor("mistExtra")};
overflow: hidden;
`
export const ProductUpdateYouTubeVideo = ({
value,
}: ComponentWithValueProps<string>) => {
const [isPlaying, { set: play, unset: pause }] = useBoolean(false)
return (
<IntersectionAware<HTMLDivElement>
render={({ ref, wasIntersected }) => (
<VStack fullWidth ref={ref}>
{wasIntersected && (
<ElementSizeAware
render={({ setElement, size }) => {
return (
<VideoWrapper ref={setElement}>
{size && (
<YouTubePlayer
isActive
width={size.width}
height={size.width * youTubeVideoRatio}
url={value}
playing={isPlaying}
onPause={pause}
onPlay={play}
config={{
youtube: {
playerVars: { autoplay: 0, controls: 1 },
},
}}
/>
)}
</VideoWrapper>
)
}}
/>
)}
</VStack>
)}
/>
)
}
To ensure we only render content when there are social media links or update items, we use the NonEmptyOnly
component from RadzionKit. This component checks if the array (socials or items) contains any elements and only renders the content if it's not empty. This prevents unnecessary rendering of empty sections and keeps the UI clean.
import { ReactNode } from "react"
import { ComponentWithValueProps } from "../props"
type NonEmptyOnlyProps<T> = Partial<ComponentWithValueProps<T[]>> & {
render: (array: T[]) => ReactNode
}
export function NonEmptyOnly<T>({ value, render }: NonEmptyOnlyProps<T>) {
if (value && value.length > 0) {
return <>{render(value)}</>
}
return null
}
In the app, we display a dot next to the bell icon when there are new updates the user hasn't seen. To handle this, we simply update the viewedNewFeaturesAt
timestamp when the user visits the "What's New" page. This is done using the useUpdateUserMutation
hook, which triggers an update when the component mounts, marking the updates as viewed.
import { useUpdateUserMutation } from "@increaser/ui/user/mutations/useUpdateUserMutation"
import { useEffect } from "react"
import { ProductUpdatesList } from "./ProductUpdatesList"
export const ProductUpdates = () => {
const { mutate } = useUpdateUserMutation()
useEffect(() => {
mutate({
viewedNewFeaturesAt: Date.now(),
})
}, [mutate])
return <ProductUpdatesList />
}
In the FeaturesNavigationItem
component, we display a notification indicator (a pill with a count) next to the bell icon when there are new product updates that the user hasn't seen. The useUser
hook provides the user's data, and useMemo
calculates how many updates have been released since the user's last visit to the "What's New" page, using the viewedNewFeaturesAt
timestamp or their registration date. If there are unseen updates, the number of updates is displayed in the Pill
styled component, otherwise, the bell icon is displayed without the indicator. The user can click the bell icon, which links to the updates page.
import Link from "next/link"
import { getAppPath } from "@increaser/ui/navigation/app"
import { useUser } from "@increaser/ui/user/state/user"
import { useMemo } from "react"
import { productUpdates } from "@increaser/changelog/productUpdates"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { round } from "@lib/ui/css/round"
import { centerContent } from "@lib/ui/css/centerContent"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { BellIcon } from "@lib/ui/icons/BellIcon"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { HeaderActionButton } from "../navigation/HeaderActionButton"
const Pill = styled.div`
position: absolute;
right: -12px;
top: -10px;
background: ${getColor("primary")};
${round};
font-weight: 500;
font-size: 12px;
color: ${getColor("contrast")};
${centerContent};
min-width: 20px;
min-height: 20px;
${horizontalPadding(4)}
`
const Wrapper = styled(IconWrapper)`
position: relative;
overflow: visible;
`
export const FeaturesNavigationItem = () => {
const user = useUser()
const newFeaturesCount = useMemo(() => {
if (!user) {
return 0
}
const viewedNewFeaturesAt =
user.viewedNewFeaturesAt || user.registrationDate
return productUpdates.filter(
(update) => update.releasedAt > viewedNewFeaturesAt
).length
}, [user])
return (
<Link href={getAppPath("updates")}>
<HeaderActionButton>
<Wrapper>
<BellIcon />
{newFeaturesCount > 0 && <Pill>{newFeaturesCount}</Pill>}
</Wrapper>
</HeaderActionButton>
</Link>
)
}
In summary, building a "What's New" feature helps keep users informed and engaged by showcasing the latest updates across your app, website, and social media. By streamlining the process with automated scripts, reusable components, and a centralized changelog, you ensure consistency and efficiency. Whether it’s displaying updates in-app or posting them across platforms, this setup keeps your product development transparent and user-focused.