Revert "Notification detail, grouping, and settings control [wip] (#403)"
This reverts commit 37c7f909a3
.
This commit is contained in:
parent
37c7f909a3
commit
07f2d390e5
|
@ -9,7 +9,6 @@ export type Notification = {
|
||||||
|
|
||||||
sourceId?: string
|
sourceId?: string
|
||||||
sourceType?: notification_source_types
|
sourceType?: notification_source_types
|
||||||
sourceUpdateType?: notification_source_update_types
|
|
||||||
sourceContractId?: string
|
sourceContractId?: string
|
||||||
sourceUserName?: string
|
sourceUserName?: string
|
||||||
sourceUserUsername?: string
|
sourceUserUsername?: string
|
||||||
|
@ -21,24 +20,10 @@ export type notification_source_types =
|
||||||
| 'bet'
|
| 'bet'
|
||||||
| 'answer'
|
| 'answer'
|
||||||
| 'liquidity'
|
| 'liquidity'
|
||||||
| 'follow'
|
|
||||||
| 'tip'
|
|
||||||
| 'admin_message'
|
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_reason_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
| 'updated'
|
| 'updated'
|
||||||
| 'resolved'
|
| 'resolved'
|
||||||
| 'deleted'
|
| 'tagged'
|
||||||
| 'closed'
|
| 'replied'
|
||||||
|
|
||||||
export type notification_reason_types =
|
|
||||||
| 'tagged_user'
|
|
||||||
| 'on_users_contract'
|
|
||||||
| 'on_contract_with_users_shares_in'
|
|
||||||
| 'on_contract_with_users_shares_out'
|
|
||||||
| 'on_contract_with_users_answer'
|
|
||||||
| 'on_contract_with_users_comment'
|
|
||||||
| 'reply_to_users_answer'
|
|
||||||
| 'reply_to_users_comment'
|
|
||||||
| 'on_new_follow'
|
|
||||||
|
|
|
@ -36,7 +36,4 @@ export type PrivateUser = {
|
||||||
initialDeviceToken?: string
|
initialDeviceToken?: string
|
||||||
initialIpAddress?: string
|
initialIpAddress?: string
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
notificationPreferences?: notification_subscribe_types
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type notification_subscribe_types = 'all' | 'less' | 'none'
|
|
||||||
|
|
|
@ -2,34 +2,28 @@ import * as admin from 'firebase-admin'
|
||||||
import {
|
import {
|
||||||
Notification,
|
Notification,
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
notification_source_update_types,
|
|
||||||
notification_source_types,
|
notification_source_types,
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getUserByUsername, getValues } from './utils'
|
import { getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { getContractBetMetrics } from '../../common/calculate'
|
|
||||||
import { removeUndefinedProps } from '../../common/util/object'
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
[userId: string]: { reason: notification_reason_types }
|
[userId: string]: { text: string; reason: notification_reason_types }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createNotification = async (
|
export const createNotification = async (
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
sourceType: notification_source_types,
|
sourceType: notification_source_types,
|
||||||
sourceUpdateType: notification_source_update_types,
|
reason: notification_reason_types,
|
||||||
|
sourceContract: Contract,
|
||||||
sourceUser: User,
|
sourceUser: User,
|
||||||
idempotencyKey: string,
|
idempotencyKey: string
|
||||||
sourceContract?: Contract,
|
|
||||||
relatedSourceType?: notification_source_types,
|
|
||||||
relatedUserId?: string,
|
|
||||||
sourceText?: string
|
|
||||||
) => {
|
) => {
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
userId: string,
|
||||||
|
@ -52,170 +46,128 @@ export const createNotification = async (
|
||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id: idempotencyKey,
|
id: idempotencyKey,
|
||||||
userId,
|
userId,
|
||||||
|
reasonText: userToReasonTexts[userId].text,
|
||||||
reason: userToReasonTexts[userId].reason,
|
reason: userToReasonTexts[userId].reason,
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId,
|
sourceId,
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceUpdateType,
|
sourceContractId: sourceContract.id,
|
||||||
sourceContractId: sourceContract?.id,
|
|
||||||
sourceUserName: sourceUser.name,
|
sourceUserName: sourceUser.name,
|
||||||
sourceUserUsername: sourceUser.username,
|
sourceUserUsername: sourceUser.username,
|
||||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||||
}
|
}
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(notification)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyRepliedUsers = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
!relatedSourceType ||
|
|
||||||
!relatedUserId ||
|
|
||||||
!shouldGetNotification(relatedUserId, userToReasonTexts)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if (relatedSourceType === 'comment') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_comment',
|
|
||||||
}
|
|
||||||
} else if (relatedSourceType === 'answer') {
|
|
||||||
userToReasonTexts[relatedUserId] = {
|
|
||||||
reason: 'reply_to_users_answer',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyFollowedUser = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
followedUserId: string
|
|
||||||
) => {
|
|
||||||
if (shouldGetNotification(followedUserId, userToReasonTexts))
|
|
||||||
userToReasonTexts[followedUserId] = {
|
|
||||||
reason: 'on_new_follow',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyTaggedUsers = async (userToReasonTexts: user_to_reason_texts) => {
|
|
||||||
if (!sourceText) return
|
|
||||||
const taggedUsers = sourceText.match(/@\w+/g)
|
|
||||||
if (!taggedUsers) return
|
|
||||||
// await all get tagged users:
|
|
||||||
const users = await Promise.all(
|
|
||||||
taggedUsers.map(async (username) => {
|
|
||||||
return await getUserByUsername(username.slice(1))
|
|
||||||
})
|
|
||||||
)
|
|
||||||
users.forEach((taggedUser) => {
|
|
||||||
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
|
|
||||||
userToReasonTexts[taggedUser.id] = {
|
|
||||||
reason: 'tagged_user',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyContractCreator = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
|
||||||
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
|
|
||||||
userToReasonTexts[sourceContract.creatorId] = {
|
|
||||||
reason: 'on_users_contract',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyOtherAnswerersOnContract = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
|
||||||
const answers = await getValues<Answer>(
|
|
||||||
firestore
|
|
||||||
.collection('contracts')
|
|
||||||
.doc(sourceContract.id)
|
|
||||||
.collection('answers')
|
|
||||||
)
|
|
||||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
|
||||||
recipientUserIds.forEach((userId) => {
|
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'on_contract_with_users_answer',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyOtherCommentersOnContract = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
|
||||||
const comments = await getValues<Comment>(
|
|
||||||
firestore
|
|
||||||
.collection('contracts')
|
|
||||||
.doc(sourceContract.id)
|
|
||||||
.collection('comments')
|
|
||||||
)
|
|
||||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
|
||||||
recipientUserIds.forEach((userId) => {
|
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'on_contract_with_users_comment',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyOtherBettorsOnContract = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
sourceContract: Contract
|
|
||||||
) => {
|
|
||||||
const betsSnap = await firestore
|
|
||||||
.collection(`contracts/${sourceContract.id}/bets`)
|
|
||||||
.get()
|
|
||||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
|
||||||
// filter bets for only users that have an amount invested still
|
|
||||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
|
||||||
(userId) => {
|
|
||||||
return (
|
|
||||||
getContractBetMetrics(
|
|
||||||
sourceContract,
|
|
||||||
bets.filter((bet) => bet.userId === userId)
|
|
||||||
).invested > 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
recipientUserIds.forEach((userId) => {
|
|
||||||
if (shouldGetNotification(userId, userToReasonTexts))
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'on_contract_with_users_shares_in',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Update for liquidity.
|
// TODO: Update for liquidity.
|
||||||
|
// TODO: Find tagged users.
|
||||||
|
// TODO: Find replies to comments.
|
||||||
|
// TODO: Filter bets for only open bets.
|
||||||
// TODO: Notify users of their own closed but not resolved contracts.
|
// TODO: Notify users of their own closed but not resolved contracts.
|
||||||
const getUsersToNotify = async () => {
|
if (
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
sourceType === 'comment' ||
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
sourceType === 'answer' ||
|
||||||
if (
|
sourceType === 'contract'
|
||||||
sourceContract &&
|
) {
|
||||||
(sourceType === 'comment' ||
|
const reasonTextPretext = getReasonTextFromReason(sourceType, reason)
|
||||||
sourceType === 'answer' ||
|
|
||||||
sourceType === 'contract')
|
|
||||||
) {
|
|
||||||
if (sourceType === 'comment') {
|
|
||||||
await notifyRepliedUsers(userToReasonTexts)
|
|
||||||
await notifyTaggedUsers(userToReasonTexts)
|
|
||||||
}
|
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherBettorsOnContract(userToReasonTexts, sourceContract)
|
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
|
||||||
} else if (sourceType === 'follow' && relatedUserId) {
|
|
||||||
await notifyFollowedUser(userToReasonTexts, relatedUserId)
|
|
||||||
}
|
|
||||||
return userToReasonTexts
|
|
||||||
}
|
|
||||||
|
|
||||||
const userToReasonTexts = await getUsersToNotify()
|
const notifyContractCreator = async (
|
||||||
await createUsersNotifications(userToReasonTexts)
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
|
||||||
|
userToReasonTexts[sourceContract.creatorId] = {
|
||||||
|
text: `${reasonTextPretext} your question`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherAnswerersOnContract = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
const answers = await getValues<Answer>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(sourceContract.id)
|
||||||
|
.collection('answers')
|
||||||
|
)
|
||||||
|
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||||
|
recipientUserIds.forEach((userId) => {
|
||||||
|
if (shouldGetNotification(userId, userToReasonTexts))
|
||||||
|
userToReasonTexts[userId] = {
|
||||||
|
text: `${reasonTextPretext} a question you submitted an answer to`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherCommentersOnContract = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
const comments = await getValues<Comment>(
|
||||||
|
firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(sourceContract.id)
|
||||||
|
.collection('comments')
|
||||||
|
)
|
||||||
|
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||||
|
recipientUserIds.forEach((userId) => {
|
||||||
|
if (shouldGetNotification(userId, userToReasonTexts))
|
||||||
|
userToReasonTexts[userId] = {
|
||||||
|
text: `${reasonTextPretext} a question you commented on`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherBettorsOnContract = async (
|
||||||
|
userToReasonTexts: user_to_reason_texts
|
||||||
|
) => {
|
||||||
|
const betsSnap = await firestore
|
||||||
|
.collection(`contracts/${sourceContract.id}/bets`)
|
||||||
|
.get()
|
||||||
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
|
const recipientUserIds = uniq(bets.map((bet) => bet.userId))
|
||||||
|
recipientUserIds.forEach((userId) => {
|
||||||
|
if (shouldGetNotification(userId, userToReasonTexts))
|
||||||
|
userToReasonTexts[userId] = {
|
||||||
|
text: `${reasonTextPretext} a question you bet on`,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUsersToNotify = async () => {
|
||||||
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
await notifyContractCreator(userToReasonTexts)
|
||||||
|
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
||||||
|
await notifyOtherCommentersOnContract(userToReasonTexts)
|
||||||
|
await notifyOtherBettorsOnContract(userToReasonTexts)
|
||||||
|
return userToReasonTexts
|
||||||
|
}
|
||||||
|
|
||||||
|
const userToReasonTexts = await getUsersToNotify()
|
||||||
|
await createUsersNotifications(userToReasonTexts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReasonTextFromReason(
|
||||||
|
source: notification_source_types,
|
||||||
|
reason: notification_reason_types
|
||||||
|
) {
|
||||||
|
// TODO: Find tagged users.
|
||||||
|
// TODO: Find replies to comments.
|
||||||
|
switch (source) {
|
||||||
|
case 'comment':
|
||||||
|
return 'commented on'
|
||||||
|
case 'contract':
|
||||||
|
return reason
|
||||||
|
case 'answer':
|
||||||
|
return 'answered'
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid notification reason')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,4 +30,3 @@ export * from './market-close-emails'
|
||||||
export * from './add-liquidity'
|
export * from './add-liquidity'
|
||||||
export * from './on-create-answer'
|
export * from './on-create-answer'
|
||||||
export * from './on-update-contract'
|
export * from './on-update-contract'
|
||||||
export * from './on-follow-user'
|
|
||||||
|
|
|
@ -25,8 +25,8 @@ export const onCreateAnswer = functions.firestore
|
||||||
answer.id,
|
answer.id,
|
||||||
'answer',
|
'answer',
|
||||||
'created',
|
'created',
|
||||||
|
contract,
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId
|
||||||
contract
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -30,6 +30,15 @@ export const onCreateComment = functions
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
comment.id,
|
||||||
|
'comment',
|
||||||
|
'created',
|
||||||
|
contract,
|
||||||
|
commentCreator,
|
||||||
|
eventId
|
||||||
|
)
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
|
@ -62,27 +71,6 @@ export const onCreateComment = functions
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<Comment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
)
|
)
|
||||||
const relatedSourceType = comment.replyToCommentId
|
|
||||||
? 'comment'
|
|
||||||
: comment.answerOutcome
|
|
||||||
? 'answer'
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const relatedUser = comment.replyToCommentId
|
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
|
||||||
: answer?.userId
|
|
||||||
|
|
||||||
await createNotification(
|
|
||||||
comment.id,
|
|
||||||
'comment',
|
|
||||||
'created',
|
|
||||||
commentCreator,
|
|
||||||
eventId,
|
|
||||||
contract,
|
|
||||||
relatedSourceType,
|
|
||||||
relatedUser,
|
|
||||||
comment.text
|
|
||||||
)
|
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
contract.creatorId,
|
contract.creatorId,
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import { getUser } from './utils'
|
|
||||||
import { createNotification } from './create-notification'
|
|
||||||
|
|
||||||
export const onFollowUser = functions.firestore
|
|
||||||
.document('users/{userId}/follows/{followedUserId}')
|
|
||||||
.onCreate(async (change, context) => {
|
|
||||||
const { userId } = context.params as {
|
|
||||||
userId: string
|
|
||||||
}
|
|
||||||
const { eventId } = context
|
|
||||||
|
|
||||||
const follow = change.data() as { userId: string; timestamp: number }
|
|
||||||
|
|
||||||
const followingUser = await getUser(userId)
|
|
||||||
if (!followingUser) throw new Error('Could not find following user')
|
|
||||||
|
|
||||||
await createNotification(
|
|
||||||
followingUser.id,
|
|
||||||
'follow',
|
|
||||||
'created',
|
|
||||||
followingUser,
|
|
||||||
eventId,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
follow.userId
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -18,9 +18,9 @@ export const onUpdateContract = functions.firestore
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'resolved',
|
'resolved',
|
||||||
|
contract,
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId
|
||||||
contract
|
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
|
@ -30,9 +30,9 @@ export const onUpdateContract = functions.firestore
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'updated',
|
'updated',
|
||||||
|
contract,
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId
|
||||||
contract
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,7 +8,6 @@ export function ChoicesToggleGroup(props: {
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
setChoice: (p: number | string) => void
|
setChoice: (p: number | string) => void
|
||||||
className?: string
|
className?: string
|
||||||
toggleClassName?: string
|
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
|
@ -18,7 +17,6 @@ export function ChoicesToggleGroup(props: {
|
||||||
choicesMap,
|
choicesMap,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
toggleClassName,
|
|
||||||
} = props
|
} = props
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
@ -39,7 +37,7 @@ export function ChoicesToggleGroup(props: {
|
||||||
: 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
|
: 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
|
||||||
'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
|
'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
|
||||||
"hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
|
"hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
|
||||||
toggleClassName
|
className
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|
|
@ -2,26 +2,32 @@ import { BellIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Notification } from 'common/notification'
|
||||||
|
import { listenForNotifications } from 'web/lib/firebase/notifications'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useNotifications } from 'web/hooks/use-notifications'
|
|
||||||
|
|
||||||
export default function NotificationsIcon(props: { className?: string }) {
|
export default function NotificationsIcon(props: { className?: string }) {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const notifications = useNotifications(user?.id, { unseenOnly: true })
|
const [notifications, setNotifications] = useState<
|
||||||
const [seen, setSeen] = useState(false)
|
Notification[] | undefined
|
||||||
|
>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.pathname.endsWith('notifications')) return setSeen(true)
|
if (router.pathname.endsWith('notifications')) return setNotifications([])
|
||||||
else setSeen(false)
|
|
||||||
}, [router.pathname])
|
}, [router.pathname])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) return listenForNotifications(user.id, setNotifications, true)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('justify-center')}>
|
<Row className={clsx('justify-center')}>
|
||||||
<div className={'relative'}>
|
<div className={'relative'}>
|
||||||
{!seen && notifications && notifications.length > 0 && (
|
{notifications && notifications.length > 0 && (
|
||||||
<div className="absolute mt-0.5 ml-3.5 min-h-[10px] min-w-[10px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-0 lg:ml-2.5"></div>
|
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
||||||
|
{notifications.length}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<BellIcon className={clsx(props.className)} />
|
<BellIcon className={clsx(props.className)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { listenForPrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
|
||||||
import { Notification } from 'common/notification'
|
|
||||||
import { listenForNotifications } from 'web/lib/firebase/notifications'
|
|
||||||
|
|
||||||
export function useNotifications(
|
|
||||||
userId: string | undefined,
|
|
||||||
options: { unseenOnly: boolean }
|
|
||||||
) {
|
|
||||||
const { unseenOnly } = options
|
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
|
||||||
const [userAppropriateNotifications, setUserAppropriateNotifications] =
|
|
||||||
useState<Notification[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userId) listenForPrivateUser(userId, setPrivateUser)
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (privateUser)
|
|
||||||
return listenForNotifications(
|
|
||||||
privateUser.id,
|
|
||||||
setNotifications,
|
|
||||||
unseenOnly
|
|
||||||
)
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!privateUser) return
|
|
||||||
|
|
||||||
const notificationsToShow = getAppropriateNotifications(
|
|
||||||
notifications,
|
|
||||||
privateUser.notificationPreferences
|
|
||||||
)
|
|
||||||
setUserAppropriateNotifications(notificationsToShow)
|
|
||||||
}, [privateUser, notifications])
|
|
||||||
|
|
||||||
return userAppropriateNotifications
|
|
||||||
}
|
|
||||||
|
|
||||||
const lessPriorityReasons = [
|
|
||||||
'on_contract_with_users_comment',
|
|
||||||
'on_contract_with_users_answer',
|
|
||||||
'on_contract_with_users_shares_out',
|
|
||||||
// Not sure if users will want to see these w/ less:
|
|
||||||
// 'on_contract_with_users_shares_in',
|
|
||||||
]
|
|
||||||
|
|
||||||
function getAppropriateNotifications(
|
|
||||||
notifications: Notification[],
|
|
||||||
notificationPreferences?: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (notificationPreferences === 'all') return notifications
|
|
||||||
if (notificationPreferences === 'less')
|
|
||||||
return notifications.filter(
|
|
||||||
(n) =>
|
|
||||||
n.reason &&
|
|
||||||
// Show all contract notifications
|
|
||||||
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
|
|
||||||
)
|
|
||||||
if (notificationPreferences === 'none') return []
|
|
||||||
|
|
||||||
return notifications
|
|
||||||
}
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
notification_reason_types,
|
notification_reason_types,
|
||||||
notification_source_types,
|
notification_source_types,
|
||||||
} from 'common/notification'
|
} from 'common/notification'
|
||||||
|
import { listenForNotifications } from 'web/lib/firebase/notifications'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Page } from 'web/components/page'
|
import { Page } from 'web/components/page'
|
||||||
|
@ -18,90 +19,22 @@ import { Comment } from 'web/lib/firebase/comments'
|
||||||
import { getValue } from 'web/lib/firebase/utils'
|
import { getValue } from 'web/lib/firebase/utils'
|
||||||
import Custom404 from 'web/pages/404'
|
import Custom404 from 'web/pages/404'
|
||||||
import { UserLink } from 'web/components/user-page'
|
import { UserLink } from 'web/components/user-page'
|
||||||
import { notification_subscribe_types, PrivateUser } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useContract } from 'web/hooks/use-contract'
|
import { useContract } from 'web/hooks/use-contract'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
|
||||||
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
|
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { groupBy, map } from 'lodash'
|
|
||||||
import { UsersIcon } from '@heroicons/react/solid'
|
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
|
||||||
import { Linkify } from 'web/components/linkify'
|
|
||||||
import {
|
|
||||||
FreeResponseOutcomeLabel,
|
|
||||||
OutcomeLabel,
|
|
||||||
} from 'web/components/outcome-label'
|
|
||||||
import { useNotifications } from 'web/hooks/use-notifications'
|
|
||||||
import { getContractFromId } from 'web/lib/firebase/contracts'
|
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
|
|
||||||
type NotificationGroup = {
|
|
||||||
notifications: Notification[]
|
|
||||||
sourceContractId: string
|
|
||||||
isSeen: boolean
|
|
||||||
timePeriod: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const [allNotificationGroups, setAllNotificationsGroups] = useState<
|
const [notifications, setNotifications] = useState<
|
||||||
NotificationGroup[]
|
Notification[] | undefined
|
||||||
>([])
|
>()
|
||||||
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
|
|
||||||
NotificationGroup[]
|
|
||||||
>([])
|
|
||||||
const notifications = useNotifications(user?.id, { unseenOnly: false })
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const notificationIdsToShow = notifications.map(
|
if (user) return listenForNotifications(user.id, setNotifications)
|
||||||
(notification) => notification.id
|
}, [user])
|
||||||
)
|
|
||||||
// Hide notifications the user doesn't want to see.
|
|
||||||
const notificationIdsToHide = notifications
|
|
||||||
.filter(
|
|
||||||
(notification) => !notificationIdsToShow.includes(notification.id)
|
|
||||||
)
|
|
||||||
.map((notification) => notification.id)
|
|
||||||
|
|
||||||
// Because hidden notifications won't be rendered, set them to seen here
|
if (!user) {
|
||||||
setNotificationsAsSeen(
|
// TODO: return sign in page
|
||||||
notifications.filter((n) => notificationIdsToHide.includes(n.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Group notifications by contract and 24-hour time period.
|
|
||||||
const allGroupedNotifications = groupNotifications(
|
|
||||||
notifications,
|
|
||||||
notificationIdsToHide
|
|
||||||
)
|
|
||||||
|
|
||||||
// Don't add notifications that are already visible or have been seen.
|
|
||||||
const currentlyVisibleUnseenNotificationIds = Object.values(
|
|
||||||
unseenNotificationGroups
|
|
||||||
)
|
|
||||||
.map((n) => n.notifications.map((n) => n.id))
|
|
||||||
.flat()
|
|
||||||
const unseenGroupedNotifications = groupNotifications(
|
|
||||||
notifications.filter(
|
|
||||||
(notification) =>
|
|
||||||
!notification.isSeen ||
|
|
||||||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
|
|
||||||
),
|
|
||||||
notificationIdsToHide
|
|
||||||
)
|
|
||||||
setAllNotificationsGroups(allGroupedNotifications)
|
|
||||||
setUnseenNotificationGroups(unseenGroupedNotifications)
|
|
||||||
|
|
||||||
// We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [notifications])
|
|
||||||
|
|
||||||
if (user === undefined) {
|
|
||||||
return <LoadingIndicator />
|
|
||||||
}
|
|
||||||
if (user === null) {
|
|
||||||
return <Custom404 />
|
return <Custom404 />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,55 +47,18 @@ export default function Notifications() {
|
||||||
className={'pb-2 pt-1 '}
|
className={'pb-2 pt-1 '}
|
||||||
defaultIndex={0}
|
defaultIndex={0}
|
||||||
tabs={[
|
tabs={[
|
||||||
{
|
|
||||||
title: 'New Notifications',
|
|
||||||
content: (
|
|
||||||
<div className={''}>
|
|
||||||
{unseenNotificationGroups.length === 0 &&
|
|
||||||
"You don't have any new notifications."}
|
|
||||||
{unseenNotificationGroups.map((notification) =>
|
|
||||||
notification.notifications.length === 1 ? (
|
|
||||||
<NotificationItem
|
|
||||||
notification={notification.notifications[0]}
|
|
||||||
key={notification.notifications[0].id}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<NotificationGroupItem
|
|
||||||
notificationGroup={notification}
|
|
||||||
key={notification.sourceContractId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'All Notifications',
|
title: 'All Notifications',
|
||||||
content: (
|
content: (
|
||||||
<div className={''}>
|
<div className={''}>
|
||||||
{allNotificationGroups.length === 0 &&
|
{notifications &&
|
||||||
"You don't have any notifications."}
|
notifications.map((notification) => (
|
||||||
{allNotificationGroups.map((notification) =>
|
<Notification
|
||||||
notification.notifications.length === 1 ? (
|
currentUser={user}
|
||||||
<NotificationItem
|
notification={notification}
|
||||||
notification={notification.notifications[0]}
|
key={notification.id}
|
||||||
key={notification.notifications[0].id}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
))}
|
||||||
<NotificationGroupItem
|
|
||||||
notificationGroup={notification}
|
|
||||||
key={notification.sourceContractId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Settings',
|
|
||||||
content: (
|
|
||||||
<div className={''}>
|
|
||||||
<NotificationSettings />
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -173,378 +69,17 @@ export default function Notifications() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setNotificationsAsSeen = (notifications: Notification[]) => {
|
function Notification(props: {
|
||||||
notifications.forEach((notification) => {
|
currentUser: User
|
||||||
if (!notification.isSeen)
|
|
||||||
updateDoc(
|
|
||||||
doc(db, `users/${notification.userId}/notifications/`, notification.id),
|
|
||||||
{
|
|
||||||
...notification,
|
|
||||||
isSeen: true,
|
|
||||||
viewTime: new Date(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return notifications
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupNotifications(
|
|
||||||
notifications: Notification[],
|
|
||||||
hideNotificationIds: string[]
|
|
||||||
) {
|
|
||||||
// Then remove them from the list of notifications to show
|
|
||||||
notifications = notifications.filter(
|
|
||||||
(notification) => !hideNotificationIds.includes(notification.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
let notificationGroups: NotificationGroup[] = []
|
|
||||||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
|
||||||
new Date(notification.createdTime).toDateString()
|
|
||||||
)
|
|
||||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
|
||||||
// Group notifications by contract:
|
|
||||||
const groupedNotificationsByContractId = groupBy(
|
|
||||||
notificationGroupsByDay[day],
|
|
||||||
(notification) => {
|
|
||||||
return notification.sourceContractId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
notificationGroups = notificationGroups.concat(
|
|
||||||
map(groupedNotificationsByContractId, (notifications, contractId) => {
|
|
||||||
// Create a notification group for each contract within each day
|
|
||||||
const notificationGroup: NotificationGroup = {
|
|
||||||
notifications: groupedNotificationsByContractId[contractId].sort(
|
|
||||||
(a, b) => {
|
|
||||||
return b.createdTime - a.createdTime
|
|
||||||
}
|
|
||||||
),
|
|
||||||
sourceContractId: contractId,
|
|
||||||
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
|
|
||||||
timePeriod: day,
|
|
||||||
}
|
|
||||||
return notificationGroup
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return notificationGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationGroupItem(props: {
|
|
||||||
notificationGroup: NotificationGroup
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { notificationGroup, className } = props
|
|
||||||
const { sourceContractId, notifications } = notificationGroup
|
|
||||||
const contract = useContract(sourceContractId ?? '')
|
|
||||||
const numSummaryLines = 3
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!contract) return
|
|
||||||
setNotificationsAsSeen(notifications)
|
|
||||||
}, [contract, notifications])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
|
|
||||||
className,
|
|
||||||
!expanded ? 'hover:bg-gray-100' : ''
|
|
||||||
)}
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
>
|
|
||||||
{expanded && (
|
|
||||||
<span
|
|
||||||
className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
|
|
||||||
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
|
|
||||||
<div
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
|
|
||||||
>
|
|
||||||
{'Activity on '}
|
|
||||||
<span className={'mx-1 font-bold'}>{contract?.question}</span>
|
|
||||||
</div>
|
|
||||||
<RelativeTimestamp time={notifications[0].createdTime} />
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
<div>
|
|
||||||
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
|
|
||||||
{' '}
|
|
||||||
<div className={'line-clamp-4 mt-1 gap-1 whitespace-pre-line'}>
|
|
||||||
{!expanded ? (
|
|
||||||
<>
|
|
||||||
{notifications
|
|
||||||
.slice(0, numSummaryLines)
|
|
||||||
.map((notification, i) => {
|
|
||||||
return (
|
|
||||||
<NotificationItem
|
|
||||||
notification={notification}
|
|
||||||
justSummary={true}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div className={'text-sm text-gray-500 hover:underline '}>
|
|
||||||
{notifications.length - numSummaryLines > 0
|
|
||||||
? 'And ' +
|
|
||||||
(notifications.length - numSummaryLines) +
|
|
||||||
' more...'
|
|
||||||
: ''}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{notifications.map((notification, i) => (
|
|
||||||
<NotificationItem
|
|
||||||
notification={notification}
|
|
||||||
key={notification.id}
|
|
||||||
justSummary={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={'mt-6 border-b border-gray-300'} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationSettings() {
|
|
||||||
const user = useUser()
|
|
||||||
const [notificationSettings, setNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
|
||||||
useState<notification_subscribe_types>('all')
|
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
|
||||||
const [showSettings, setShowSettings] = useState<'in-app' | 'email' | 'none'>(
|
|
||||||
'none'
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!privateUser) return
|
|
||||||
if (privateUser.notificationPreferences) {
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('none')
|
|
||||||
} else if (
|
|
||||||
!privateUser.unsubscribedFromResolutionEmails &&
|
|
||||||
!privateUser.unsubscribedFromCommentEmails &&
|
|
||||||
!privateUser.unsubscribedFromAnswerEmails
|
|
||||||
) {
|
|
||||||
setEmailNotificationSettings('all')
|
|
||||||
} else {
|
|
||||||
setEmailNotificationSettings('less')
|
|
||||||
}
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
const loading = 'Changing Notifications Settings'
|
|
||||||
const success = 'Notification Settings Changed!'
|
|
||||||
function changeEmailNotifications(newValue: notification_subscribe_types) {
|
|
||||||
if (!privateUser) return
|
|
||||||
setShowSettings('email')
|
|
||||||
if (newValue === 'all') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: false,
|
|
||||||
unsubscribedFromAnswerEmails: false,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'less') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: false,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (newValue === 'none') {
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
unsubscribedFromResolutionEmails: true,
|
|
||||||
unsubscribedFromCommentEmails: true,
|
|
||||||
unsubscribedFromAnswerEmails: true,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeInAppNotificationSettings(
|
|
||||||
newValue: notification_subscribe_types
|
|
||||||
) {
|
|
||||||
if (!privateUser) return
|
|
||||||
toast.promise(
|
|
||||||
updatePrivateUser(privateUser.id, {
|
|
||||||
notificationPreferences: newValue,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
loading,
|
|
||||||
success,
|
|
||||||
error: (err) => `${err.message}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
setShowSettings('in-app')
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (privateUser && privateUser.notificationPreferences)
|
|
||||||
setNotificationSettings(privateUser.notificationPreferences)
|
|
||||||
else setNotificationSettings('all')
|
|
||||||
}, [privateUser])
|
|
||||||
|
|
||||||
if (!privateUser) {
|
|
||||||
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
|
||||||
label: string
|
|
||||||
highlight: boolean
|
|
||||||
}) {
|
|
||||||
const { label, highlight } = props
|
|
||||||
return (
|
|
||||||
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
|
||||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
|
||||||
{label}
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'p-2'}>
|
|
||||||
<div>In App Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={notificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeInAppNotificationSettings(
|
|
||||||
choice as notification_subscribe_types
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
|
||||||
<ChoicesToggleGroup
|
|
||||||
currentChoice={emailNotificationSettings}
|
|
||||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
|
||||||
setChoice={(choice) =>
|
|
||||||
changeEmailNotifications(choice as notification_subscribe_types)
|
|
||||||
}
|
|
||||||
className={'col-span-4 p-2'}
|
|
||||||
toggleClassName={'w-24'}
|
|
||||||
/>
|
|
||||||
<div className={'mt-4 text-base'}>
|
|
||||||
{showSettings === 'in-app' ? (
|
|
||||||
<div>
|
|
||||||
<div className={''}>
|
|
||||||
You will receive notifications for:
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Resolutions on questions you've interacted with"}
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={'Activity on your own questions, comments, & answers'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
highlight={notificationSettings !== 'none'}
|
|
||||||
label={"Activity on questions you're betting on"}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Activity on questions you've ever bet or commented on"}
|
|
||||||
highlight={notificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : showSettings === 'email' ? (
|
|
||||||
<div>
|
|
||||||
You will receive emails for:
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Resolutions on questions you're betting on"}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Closure of your questions'}
|
|
||||||
highlight={emailNotificationSettings !== 'none'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={'Activity on your questions'}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
<NotificationSettingLine
|
|
||||||
label={"Activity on questions you've answered or commented on"}
|
|
||||||
highlight={emailNotificationSettings === 'all'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNotificationSummaryText(
|
|
||||||
sourceId: string,
|
|
||||||
sourceContractId: string,
|
|
||||||
sourceType: 'answer' | 'comment',
|
|
||||||
setText: (text: string) => void
|
|
||||||
) {
|
|
||||||
if (sourceType === 'answer') {
|
|
||||||
const answer = await getValue<Answer>(
|
|
||||||
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
|
|
||||||
)
|
|
||||||
setText(answer?.text ?? '')
|
|
||||||
} else {
|
|
||||||
const comment = await getValue<Comment>(
|
|
||||||
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
|
|
||||||
)
|
|
||||||
setText(comment?.text ?? '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationItem(props: {
|
|
||||||
notification: Notification
|
notification: Notification
|
||||||
justSummary?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const { notification, justSummary } = props
|
const { notification, currentUser } = props
|
||||||
const {
|
const {
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceContractId,
|
sourceContractId,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
sourceUserName,
|
sourceUserName,
|
||||||
sourceUserAvatarUrl,
|
sourceUserAvatarUrl,
|
||||||
reasonText,
|
reasonText,
|
||||||
|
@ -552,41 +87,42 @@ function NotificationItem(props: {
|
||||||
sourceUserUsername,
|
sourceUserUsername,
|
||||||
createdTime,
|
createdTime,
|
||||||
} = notification
|
} = notification
|
||||||
const [notificationText, setNotificationText] = useState<string>('')
|
const [subText, setSubText] = useState<string>('')
|
||||||
const [contract, setContract] = useState<Contract | null>(null)
|
const contract = useContract(sourceContractId ?? '')
|
||||||
useEffect(() => {
|
|
||||||
if (!sourceContractId) return
|
|
||||||
getContractFromId(sourceContractId).then((contract) => {
|
|
||||||
if (contract) setContract(contract)
|
|
||||||
})
|
|
||||||
}, [sourceContractId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contract || !sourceContractId) return
|
if (!contract) return
|
||||||
if (sourceType === 'contract') {
|
if (sourceType === 'contract') {
|
||||||
// We don't handle anything other than contract updates & resolution yet.
|
setSubText(contract.question)
|
||||||
if (contract.resolution) setNotificationText(contract.resolution)
|
|
||||||
else setNotificationText(contract.question)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!sourceId) return
|
}, [contract, sourceType])
|
||||||
|
|
||||||
if (sourceType === 'answer' || sourceType === 'comment') {
|
|
||||||
getNotificationSummaryText(
|
|
||||||
sourceId,
|
|
||||||
sourceContractId,
|
|
||||||
sourceType,
|
|
||||||
setNotificationText
|
|
||||||
)
|
|
||||||
} else if (reasonText) {
|
|
||||||
// Handle arbitrary notifications with reason text here.
|
|
||||||
setNotificationText(reasonText)
|
|
||||||
}
|
|
||||||
}, [contract, reasonText, sourceContractId, sourceId, sourceType])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotificationsAsSeen([notification])
|
if (!sourceContractId || !sourceId) return
|
||||||
}, [notification])
|
|
||||||
|
if (sourceType === 'answer') {
|
||||||
|
getValue<Answer>(
|
||||||
|
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
|
||||||
|
).then((answer) => {
|
||||||
|
setSubText(answer?.text || '')
|
||||||
|
})
|
||||||
|
} else if (sourceType === 'comment') {
|
||||||
|
getValue<Comment>(
|
||||||
|
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
|
||||||
|
).then((comment) => {
|
||||||
|
setSubText(comment?.text || '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [sourceContractId, sourceId, sourceType])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contract || !notification || notification.isSeen) return
|
||||||
|
updateDoc(doc(db, `users/${currentUser.id}/notifications/`, id), {
|
||||||
|
...notification,
|
||||||
|
isSeen: true,
|
||||||
|
viewTime: new Date(),
|
||||||
|
})
|
||||||
|
}, [notification, contract, currentUser, id, userId])
|
||||||
|
|
||||||
function getSourceUrl(sourceId?: string) {
|
function getSourceUrl(sourceId?: string) {
|
||||||
if (!contract) return ''
|
if (!contract) return ''
|
||||||
|
@ -608,53 +144,8 @@ function NotificationItem(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNotificationContractResolution() {
|
|
||||||
return sourceType === 'contract' && contract?.resolution
|
|
||||||
}
|
|
||||||
|
|
||||||
if (justSummary) {
|
|
||||||
return (
|
|
||||||
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
|
|
||||||
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
|
|
||||||
<div className={'flex pl-1 sm:pl-0'}>
|
|
||||||
<UserLink
|
|
||||||
name={sourceUserName || ''}
|
|
||||||
username={sourceUserUsername || ''}
|
|
||||||
className={'mr-0 flex-shrink-0'}
|
|
||||||
/>
|
|
||||||
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
|
|
||||||
{sourceType &&
|
|
||||||
reason &&
|
|
||||||
getReasonTextFromReason(
|
|
||||||
sourceType,
|
|
||||||
reason,
|
|
||||||
contract,
|
|
||||||
true
|
|
||||||
).replace(' on', '')}
|
|
||||||
<div className={'ml-1 text-black'}>
|
|
||||||
{contract ? (
|
|
||||||
<NotificationTextLabel
|
|
||||||
contract={contract}
|
|
||||||
notificationText={notificationText}
|
|
||||||
className={'line-clamp-1'}
|
|
||||||
/>
|
|
||||||
) : sourceType != 'follow' ? (
|
|
||||||
<LoadingIndicator
|
|
||||||
spinnerClassName={'border-gray-500 h-4 w-4'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
|
<div className={'bg-white px-1 pt-6 text-sm sm:px-4'}>
|
||||||
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
<Row className={'items-center text-gray-500 sm:justify-start'}>
|
||||||
<Avatar
|
<Avatar
|
||||||
avatarUrl={sourceUserAvatarUrl}
|
avatarUrl={sourceUserAvatarUrl}
|
||||||
|
@ -665,7 +156,7 @@ function NotificationItem(props: {
|
||||||
<div className={'flex-1 overflow-hidden sm:flex'}>
|
<div className={'flex-1 overflow-hidden sm:flex'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0'
|
'flex max-w-sm shrink overflow-hidden text-ellipsis sm:max-w-md'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserLink
|
<UserLink
|
||||||
|
@ -677,11 +168,12 @@ function NotificationItem(props: {
|
||||||
href={getSourceUrl(sourceId)}
|
href={getSourceUrl(sourceId)}
|
||||||
className={'inline-flex overflow-hidden text-ellipsis pl-1'}
|
className={'inline-flex overflow-hidden text-ellipsis pl-1'}
|
||||||
>
|
>
|
||||||
{sourceType && reason && (
|
{sourceType && reason ? (
|
||||||
<div className={'inline truncate'}>
|
<div className={'inline truncate'}>
|
||||||
{getReasonTextFromReason(sourceType, reason, contract)}
|
{getReasonTextFromReason(sourceType, reason, contract)}
|
||||||
<span className={'mx-1 font-bold'}>{contract?.question}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
reasonText
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -697,16 +189,11 @@ function NotificationItem(props: {
|
||||||
</Row>
|
</Row>
|
||||||
<a href={getSourceUrl(sourceId)}>
|
<a href={getSourceUrl(sourceId)}>
|
||||||
<div className={'mt-1 md:text-base'}>
|
<div className={'mt-1 md:text-base'}>
|
||||||
{isNotificationContractResolution() && ' Resolved:'}{' '}
|
{' '}
|
||||||
{contract ? (
|
{contract && subText === contract.question ? (
|
||||||
<NotificationTextLabel
|
<div className={'text-indigo-700 hover:underline'}>{subText}</div>
|
||||||
contract={contract}
|
|
||||||
notificationText={notificationText}
|
|
||||||
/>
|
|
||||||
) : sourceType != 'follow' ? (
|
|
||||||
<LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div className={'line-clamp-4 whitespace-pre-line'}>{subText}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -716,90 +203,19 @@ function NotificationItem(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationTextLabel(props: {
|
|
||||||
contract: Contract
|
|
||||||
notificationText: string
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { contract, notificationText, className } = props
|
|
||||||
if (notificationText === contract.question) {
|
|
||||||
return (
|
|
||||||
<div className={clsx('text-indigo-700 hover:underline', className)}>
|
|
||||||
{notificationText}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} else if (notificationText === contract.resolution) {
|
|
||||||
if (contract.outcomeType === 'FREE_RESPONSE') {
|
|
||||||
return (
|
|
||||||
<FreeResponseOutcomeLabel
|
|
||||||
contract={contract}
|
|
||||||
resolution={contract.resolution}
|
|
||||||
truncate={'long'}
|
|
||||||
answerClassName={className}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<OutcomeLabel
|
|
||||||
contract={contract}
|
|
||||||
outcome={contract.resolution}
|
|
||||||
truncate={'long'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className ? className : 'line-clamp-4 whitespace-pre-line'}
|
|
||||||
>
|
|
||||||
<Linkify text={notificationText} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReasonTextFromReason(
|
function getReasonTextFromReason(
|
||||||
source: notification_source_types,
|
source: notification_source_types,
|
||||||
reason: notification_reason_types,
|
reason: notification_reason_types,
|
||||||
contract: Contract | undefined | null,
|
contract: Contract | undefined
|
||||||
simple?: boolean
|
|
||||||
) {
|
) {
|
||||||
let reasonText = ''
|
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'comment':
|
case 'comment':
|
||||||
if (reason === 'reply_to_users_answer')
|
return `commented on ${contract?.question}`
|
||||||
reasonText = !simple ? 'replied to your answer on' : 'replied'
|
|
||||||
else if (reason === 'tagged_user')
|
|
||||||
reasonText = !simple ? 'tagged you in a comment on' : 'tagged you'
|
|
||||||
else if (reason === 'reply_to_users_comment')
|
|
||||||
reasonText = !simple ? 'replied to your comment on' : 'replied'
|
|
||||||
else if (reason === 'on_users_contract')
|
|
||||||
reasonText = !simple ? `commented on your question` : 'commented'
|
|
||||||
else if (reason === 'on_contract_with_users_comment')
|
|
||||||
reasonText = `commented on`
|
|
||||||
else if (reason === 'on_contract_with_users_answer')
|
|
||||||
reasonText = `commented on`
|
|
||||||
else if (reason === 'on_contract_with_users_shares_in')
|
|
||||||
reasonText = `commented`
|
|
||||||
else reasonText = `commented on`
|
|
||||||
break
|
|
||||||
case 'contract':
|
case 'contract':
|
||||||
if (contract?.resolution) reasonText = `resolved`
|
return `${reason} ${contract?.question}`
|
||||||
else reasonText = `updated`
|
|
||||||
break
|
|
||||||
case 'answer':
|
case 'answer':
|
||||||
if (reason === 'on_users_contract') reasonText = `answered your question `
|
return `answered ${contract?.question}`
|
||||||
if (reason === 'on_contract_with_users_comment') reasonText = `answered`
|
|
||||||
else if (reason === 'on_contract_with_users_answer')
|
|
||||||
reasonText = `answered`
|
|
||||||
else if (reason === 'on_contract_with_users_shares_in')
|
|
||||||
reasonText = `answered`
|
|
||||||
else reasonText = `answered`
|
|
||||||
break
|
|
||||||
case 'follow':
|
|
||||||
reasonText = 'followed you'
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
reasonText = ''
|
return ''
|
||||||
}
|
}
|
||||||
return reasonText
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user