Skip to content

Commit

Permalink
feat: implement notifications system and UI (#155)
Browse files Browse the repository at this point in the history
### TL;DR

Added notifications screen with support for friend requests, habit invites, and nudges.

https://github.com/user-attachments/assets/319a4c5f-1e35-4be7-ac01-6a8cb28ca932

### What changed?

- Added notifications screen with real-time updates and interactive responses
- Upgraded @gorhom/bottom-sheet from 4.6.3 to 5.0.6 to gain access to dynamic sizing: https://gorhom.dev/react-native-bottom-sheet/dynamic-sizing
- Created new components for notification cards and user info displays
- Implemented mock notification data and handlers
- Added single habit query hook

### How to test?

1. Navigate to the notifications tab
2. Verify different notification types display correctly:
   - Friend requests
   - Habit invites
   - Nudge reminders
3. Test notification interactions:
   - Respond to friend requests
   - Accept/decline habit invites
   - Dismiss nudges
4. Confirm user profiles and habit details load in notification modals
5. Verify time ago formatting works correctly
  • Loading branch information
owengretzinger authored Jan 27, 2025
1 parent e82a13c commit 9097a7a
Show file tree
Hide file tree
Showing 23 changed files with 625 additions and 157 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@expo/metro-runtime": "^3.2.3",
"@gorhom/bottom-sheet": "4.6.3",
"@gorhom/bottom-sheet": "5.0.6",
"@hookform/resolvers": "^3.9.0",
"@react-native-menu/menu": "^1.1.7",
"@shopify/flash-list": "1.6.4",
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/api/habits/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './types';
export * from './use-create-habit';
export * from './use-delete-habit';
export * from './use-edit-habit';
export * from './use-habit';
export * from './use-habit-completions';
export * from './use-habits';
export * from './use-press-habit-button';
27 changes: 27 additions & 0 deletions src/api/habits/mock-habits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,33 @@ export const mockHabits: { id: HabitIdT; data: DbHabitT }[] = [
},
},
},
{
id: '4' as HabitIdT,
data: {
colorName: 'blue',
createdAt: new Date('2024-01-04T00:00:00'),
description: 'Meditate for 10 minutes',
title: 'Daily Meditation',
settings: {
allowMultipleCompletions: false,
},
icon: 'star',
participants: {
['2' as UserIdT]: {
displayName: 'Jane Doe',
username: 'jane_doe',
lastActivity: new Date('2024-12-15T00:00:00'),
isOwner: true,
},
['4' as UserIdT]: {
displayName: 'Bob Johnson',
username: 'bob_johnson',
lastActivity: new Date('2024-12-14T00:00:00'),
isOwner: false,
},
},
},
},
];
export const setMockHabits = (
newHabits: { id: HabitIdT; data: DbHabitT }[],
Expand Down
50 changes: 50 additions & 0 deletions src/api/habits/use-habit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createQuery } from 'react-query-kit';

import { habitColors } from '@/ui/colors';

import { addTestDelay } from '../common';
import { type UserIdT } from '../users';
import { mockHabits } from './mock-habits';
import { type HabitT } from './types';

type Variables = { id: HabitT['id'] };
type Response = HabitT;

export const useHabit = createQuery<Response, Variables, Error>({
queryKey: ['habit'],
fetcher: async ({ id }) => {
const dbHabit = await addTestDelay(
mockHabits.find((habit) => habit.id === id),
);
if (!dbHabit) throw new Error('Habit not found');

const { data } = dbHabit;
return {
id,
...data,
color: habitColors[data.colorName],
participants: Object.fromEntries(
Object.entries(data.participants).map(
([participantId, participant]) => {
if (!participant)
throw new Error('Participant not found for habit ' + id);

return [
participantId,
{
id: participantId as UserIdT,
displayName: participant.displayName,
username: participant.username,
lastActivity: new Date(participant.lastActivity),
hasActivityToday:
participant.lastActivity.toLocaleDateString('en-CA') ===
new Date().toLocaleDateString('en-CA'),
isOwner: participant?.isOwner ?? false,
},
];
},
),
),
};
},
});
52 changes: 27 additions & 25 deletions src/api/habits/use-habits.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,35 @@ export const useHabits = createQuery<Response, Variables, Error>({
fetcher: async () => {
const dbHabits = await addTestDelay(mockHabits);

const habits: HabitT[] = dbHabits.map(({ id: habitId, data }) => ({
id: habitId,
...data,
color: habitColors[data.colorName],
participants: Object.fromEntries(
Object.entries(data.participants).map(
([participantId, participant]) => {
if (!participant)
throw new Error('Participant not found for habit ' + habitId);
const habits: HabitT[] = dbHabits
.filter((habit) => '1' in habit.data.participants)
.map(({ id: habitId, data }) => ({
id: habitId,
...data,
color: habitColors[data.colorName],
participants: Object.fromEntries(
Object.entries(data.participants).map(
([participantId, participant]) => {
if (!participant)
throw new Error('Participant not found for habit ' + habitId);

return [
participantId,
{
id: participantId as UserIdT,
displayName: participant.displayName,
username: participant.username,
lastActivity: new Date(participant.lastActivity),
hasActivityToday:
participant.lastActivity.toLocaleDateString('en-CA') ===
new Date().toLocaleDateString('en-CA'),
isOwner: participant?.isOwner ?? false,
},
];
},
return [
participantId,
{
id: participantId as UserIdT,
displayName: participant.displayName,
username: participant.username,
lastActivity: new Date(participant.lastActivity),
hasActivityToday:
participant.lastActivity.toLocaleDateString('en-CA') ===
new Date().toLocaleDateString('en-CA'),
isOwner: participant?.isOwner ?? false,
},
];
},
),
),
),
}));
}));

return habits;
},
Expand Down
1 change: 1 addition & 0 deletions src/api/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './colors-schemas';
export * from './common';
export * from './habits';
export * from './notifications';
export * from './users';
1 change: 1 addition & 0 deletions src/api/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './types';
export * from './use-notifications';
45 changes: 45 additions & 0 deletions src/api/notifications/mock-notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type HabitIdT } from '../habits';
import { type UserIdT } from '../users';
import {
type FriendNotificationT,
type HabitNotificationT,
type NotificationT,
} from './types';

const now = new Date();
const getTimeAgoDate = (seconds: number) =>
new Date(now.getTime() - seconds * 1000);

export const mockNotifications: (FriendNotificationT | HabitNotificationT)[] = [
{
type: 'friendRequest',
senderId: '3' as UserIdT,
receiverId: '1' as UserIdT,
sentAt: getTimeAgoDate(20), // 20 seconds ago
},
{
type: 'friendRequest',
senderId: '6' as UserIdT,
receiverId: '1' as UserIdT,
sentAt: getTimeAgoDate(3600), // 1 hour ago
},
{
type: 'habitInvite',
habitId: '4' as HabitIdT,
senderId: '2' as UserIdT,
receiverId: '1' as UserIdT,
sentAt: getTimeAgoDate(1800), // 30 minutes ago
},
{
type: 'nudge',
habitId: '1' as HabitIdT,
senderId: '4' as UserIdT,
receiverId: '1' as UserIdT,
sentAt: getTimeAgoDate(172800), // 2 days ago
},
];

export const setMockNotifications = (newNotifications: NotificationT[]) => {
mockNotifications.length = 0;
mockNotifications.push(...newNotifications);
};
22 changes: 14 additions & 8 deletions src/api/notifications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,29 @@ export const NotificationIdSchema = z.coerce
.string()
.transform((val) => val as NotificationIdT);

export const noticationTypeSchema = z.enum([
'habitInvite',
'nudge',
'friendRequest',
export const habitNoticationTypeSchema = z.enum(['habitInvite', 'nudge']);
export type HabitNotificationTypeT = z.infer<typeof habitNoticationTypeSchema>;

export const friendNoticationTypeSchema = z.enum(['friendRequest']);
export type FriendNotificationTypeT = z.infer<
typeof friendNoticationTypeSchema
>;

export const notificationTypeSchema = z.union([
habitNoticationTypeSchema,
friendNoticationTypeSchema,
]);
export type NotificationTypeT = z.infer<typeof notificationTypeSchema>;

export const friendNotificationSchema = z.object({
type: noticationTypeSchema,
type: friendNoticationTypeSchema,
senderId: UserIdSchema,
receiverId: UserIdSchema,
sentAt: z.date(),
});

export const habitNotificationSchema = z.object({
type: noticationTypeSchema,
type: habitNoticationTypeSchema,
habitId: HabitIdSchema,
senderId: UserIdSchema,
receiverId: UserIdSchema,
Expand All @@ -35,8 +43,6 @@ export const notificationSchema = z.union([
habitNotificationSchema,
]);

export type NotificationTypeT = z.infer<typeof noticationTypeSchema>;

export type FriendNotificationT = z.infer<typeof friendNotificationSchema>;
export type HabitNotificationT = z.infer<typeof habitNotificationSchema>;
export type NotificationT = z.infer<typeof notificationSchema>;
21 changes: 21 additions & 0 deletions src/api/notifications/use-notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createQuery } from 'react-query-kit';

import { addTestDelay } from '../common';
import { mockNotifications } from './mock-notifications';
import { type FriendNotificationT, type HabitNotificationT } from './types';

type Response = (FriendNotificationT | HabitNotificationT)[];
type Variables = void;

export const useNotifications = createQuery<Response, Variables, Error>({
queryKey: ['notifications'],
fetcher: async () => {
const myId = '1';
const notifications = await addTestDelay(
mockNotifications.filter(
(notification) => notification.receiverId === myId,
),
);
return notifications;
},
});
2 changes: 1 addition & 1 deletion src/api/users/use-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type Response = UserWithRelationshipT;
export const useUser: ReturnType<
typeof createQuery<Response, Variables, Error>
> = createQuery<Response, Variables, Error>({
queryKey: ['friend'],
queryKey: ['user'],
fetcher: async (variables) => {
const myId = '1' as UserIdT;
const user = await addTestDelay(
Expand Down
Loading

0 comments on commit 9097a7a

Please sign in to comment.