comments
This commit is contained in:
parent
52b5a490ac
commit
91e1116532
9 changed files with 226 additions and 33 deletions
|
@ -9,7 +9,7 @@ export interface paths {
|
|||
get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
From?: string
|
||||
After?: string
|
||||
Amount?: number
|
||||
AuthorId?: string
|
||||
Author?: string
|
||||
|
@ -192,6 +192,47 @@ export interface paths {
|
|||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/posts/{postId}/comments': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
get?: never
|
||||
put?: never
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path: {
|
||||
postId: string
|
||||
}
|
||||
cookie?: never
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['AddPostCommentRequest']
|
||||
'text/json': components['schemas']['AddPostCommentRequest']
|
||||
'application/*+json': components['schemas']['AddPostCommentRequest']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
}
|
||||
}
|
||||
}
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/media': {
|
||||
parameters: {
|
||||
query?: never
|
||||
|
@ -641,6 +682,11 @@ export interface paths {
|
|||
export type webhooks = Record<string, never>
|
||||
export interface components {
|
||||
schemas: {
|
||||
AddPostCommentRequest: {
|
||||
/** Format: uuid */
|
||||
authorId: string
|
||||
content: string
|
||||
}
|
||||
AddPostReactionRequest: {
|
||||
emoji: string
|
||||
}
|
||||
|
@ -709,6 +755,12 @@ export interface components {
|
|||
authorId: string
|
||||
username: string
|
||||
}
|
||||
PostCommentDto: {
|
||||
author: string
|
||||
content: string
|
||||
/** Format: date-time */
|
||||
postedOn: string
|
||||
}
|
||||
PostDto: {
|
||||
author: components['schemas']['PostAuthorDto']
|
||||
/** Format: uuid */
|
||||
|
@ -719,6 +771,7 @@ export interface components {
|
|||
/** Format: date-time */
|
||||
createdAt: string
|
||||
possibleReactions: string[]
|
||||
comments: components['schemas']['PostCommentDto'][]
|
||||
}
|
||||
PostMediaDto: {
|
||||
/** Format: uri */
|
||||
|
|
58
src/app/feed/components/NewCommentWidget.tsx
Normal file
58
src/app/feed/components/NewCommentWidget.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { useState } from 'react'
|
||||
import FancyTextEditor, {
|
||||
TextInputKeyDownEvent,
|
||||
} from '../../../components/inputs/FancyTextEditor.tsx'
|
||||
import Button from '../../../components/buttons/Button.tsx'
|
||||
import { useTranslations } from '../../i18n/translations.ts'
|
||||
|
||||
interface NewCommentWidgetProps {
|
||||
onSubmit: (content: string) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export default function NewCommentWidget({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: NewCommentWidgetProps) {
|
||||
const { t } = useTranslations()
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const onContentInput = (value: string) => {
|
||||
setContent(value)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!content.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(content)
|
||||
|
||||
setContent('')
|
||||
}
|
||||
|
||||
const onInputKeyDown = (e: TextInputKeyDownEvent) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 border-b border-gray-200">
|
||||
<FancyTextEditor
|
||||
value={content}
|
||||
onInput={onContentInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
className="mb-3"
|
||||
placeholder={t('post.editor.placeholder')}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end items-center">
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || content.trim() === ''}>
|
||||
{t('post.submit.cta')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
243
src/app/feed/components/NewPostWidget.tsx
Normal file
243
src/app/feed/components/NewPostWidget.tsx
Normal file
|
@ -0,0 +1,243 @@
|
|||
import { useState } from 'react'
|
||||
import FancyTextEditor, { TextInputKeyDownEvent } from '../../../components/inputs/FancyTextEditor.tsx'
|
||||
import Button from '../../../components/buttons/Button.tsx'
|
||||
import { openFileDialog } from '../../../utils/openFileDialog.ts'
|
||||
import makePica from 'pica'
|
||||
import { useTranslations } from '../../i18n/translations.ts'
|
||||
|
||||
interface NewPostWidgetProps {
|
||||
onSubmit: (
|
||||
content: string,
|
||||
media: { file: File; width: number; height: number }[],
|
||||
isPublic: boolean,
|
||||
) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
id: string
|
||||
file: File
|
||||
objectUrl: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
|
||||
const { t } = useTranslations()
|
||||
const [content, setContent] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
|
||||
const onContentInput = (value: string) => {
|
||||
setContent(value)
|
||||
}
|
||||
|
||||
async function onAddMediaClicked() {
|
||||
const files = await openFileDialog('image/*', true)
|
||||
if (files == null || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newAttachments = await Promise.all(Array.from(files).map(createAttachment))
|
||||
setAttachments((attachments) => [...attachments, ...newAttachments])
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!content.trim() && attachments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(content, attachments, isPublic)
|
||||
|
||||
attachments.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl))
|
||||
setContent('')
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
const handleRemoveMedia = (id: string) => {
|
||||
const attachment = attachments.find((attachment) => attachment.id === id)!
|
||||
setAttachments(attachments.filter((attachment) => attachment.id !== id))
|
||||
URL.revokeObjectURL(attachment.objectUrl)
|
||||
}
|
||||
|
||||
const onInputKeyDown = (e: TextInputKeyDownEvent) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 border-b border-gray-200">
|
||||
<FancyTextEditor
|
||||
value={content}
|
||||
onInput={onContentInput}
|
||||
onKeyDown={onInputKeyDown}
|
||||
className="mb-3"
|
||||
placeholder={t('post.editor.placeholder')}
|
||||
/>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-row flex-wrap gap-2 mb-3">
|
||||
{attachments.map(({ objectUrl, id }) => (
|
||||
<button
|
||||
key={id}
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => handleRemoveMedia(id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<img src={objectUrl} alt="" className="w-24 h-24 object-cover rounded-md" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button secondary onClick={onAddMediaClicked}>
|
||||
{t('post.add_media.cta')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="form-checkbox h-4 w-4 text-blue-600"
|
||||
/>
|
||||
<span className="text-primary-500">{t('post.public.label')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
|
||||
>
|
||||
{t('post.submit.cta')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function createAttachment(file: File): Promise<Attachment> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('not an image')
|
||||
}
|
||||
|
||||
file = await optimizeImageSize(file)
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
const { width, height } = await getImageFileDimensions(objectUrl)
|
||||
|
||||
return {
|
||||
id: getRandomId(),
|
||||
file,
|
||||
objectUrl,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
function getImageFileDimensions(objectURL: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
resolve({ width: img.width, height: img.height })
|
||||
})
|
||||
|
||||
img.src = objectURL
|
||||
})
|
||||
}
|
||||
|
||||
const pica = makePica()
|
||||
|
||||
async function optimizeImageSize(
|
||||
file: File,
|
||||
{
|
||||
targetMaxWidth = 1920,
|
||||
targetMaxHeight = 1080,
|
||||
targetSizeBytes = 500 * 1024,
|
||||
outputType = 'image/jpeg',
|
||||
quality = 0.9,
|
||||
}: {
|
||||
targetMaxWidth?: number
|
||||
targetMaxHeight?: number
|
||||
targetSizeBytes?: number
|
||||
outputType?: string
|
||||
quality?: number
|
||||
} = {},
|
||||
): Promise<File> {
|
||||
const img = document.createElement('img')
|
||||
const url = URL.createObjectURL(file)
|
||||
img.src = url
|
||||
|
||||
await img.decode()
|
||||
|
||||
console.debug('processing image', {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
targetMaxWidth,
|
||||
targetMaxHeight,
|
||||
targetSizeBytes,
|
||||
outputType,
|
||||
quality,
|
||||
})
|
||||
|
||||
const scale = Math.min(1, targetMaxWidth / img.width, targetMaxHeight / img.height)
|
||||
const width = Math.floor(img.width * scale)
|
||||
const height = Math.floor(img.height * scale)
|
||||
const originalSize = file.size
|
||||
|
||||
const srcCanvas = document.createElement('canvas')
|
||||
srcCanvas.width = img.width
|
||||
srcCanvas.height = img.height
|
||||
srcCanvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||
|
||||
const dstCanvas = document.createElement('canvas')
|
||||
dstCanvas.width = width
|
||||
dstCanvas.height = height
|
||||
|
||||
try {
|
||||
// TODO resistFingerprinting in FF and other causes this to break.
|
||||
// knowing this, i would still rather be able to post from other browsers for now
|
||||
// and will hopefully find a better solution
|
||||
await pica.resize(srcCanvas, dstCanvas)
|
||||
} catch (e) {
|
||||
console.error('cant resize image', e)
|
||||
return file
|
||||
}
|
||||
|
||||
let blob = await pica.toBlob(dstCanvas, outputType, quality)
|
||||
|
||||
while (blob.size > targetSizeBytes && quality > 0.1) {
|
||||
quality -= 0.1
|
||||
blob = await pica.toBlob(dstCanvas, outputType, quality)
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`optimized image rendered at ${Math.round(quality * 100)}% quality to ${blob.size / 1000}KB from ${originalSize / 1000}KB`,
|
||||
)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
return new File([blob], file.name, { type: file.type })
|
||||
}
|
||||
|
||||
function getRandomId() {
|
||||
if (window.isSecureContext) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
// Fallback using getRandomValues
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
|
||||
// Format according to RFC4122 version 4
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||
|
||||
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0'))
|
||||
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
|
||||
}
|
|
@ -1,30 +1,67 @@
|
|||
import { PostReaction } from '../posts/posts.ts'
|
||||
import { PostComment, PostReaction } from '../posts/posts.ts'
|
||||
import { Temporal } from '@js-temporal/polyfill'
|
||||
|
||||
interface PostTimelineProps {
|
||||
reactions: PostReaction[]
|
||||
comments: PostComment[]
|
||||
}
|
||||
|
||||
export function PostTimeline({ reactions }: PostTimelineProps) {
|
||||
export function PostTimeline({ reactions, comments }: PostTimelineProps) {
|
||||
const items = [
|
||||
...reactions.map((reaction) => ({
|
||||
timestamp: reaction.reactedOn,
|
||||
component: (
|
||||
<ReactionItem
|
||||
key={'reaction-' + reaction.authorName + reaction.reactedOn.toString()}
|
||||
reaction={reaction}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
...comments.map((comment) => ({
|
||||
timestamp: comment.postedOn,
|
||||
component: (
|
||||
<CommentItem
|
||||
key={'comment-' + comment.author + comment.postedOn.toString()}
|
||||
comment={comment}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
].toSorted((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 mb-4 px-4`}>
|
||||
{reactions.map((reaction) => (
|
||||
<div className={`flex flex-col`}>
|
||||
<div className={`text-gray-400 text-xs -mb-1`}>
|
||||
{reaction.reactedOn.toLocaleString('en-AU', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div className={`flex flex-row items-baseline text-gray-500`}>
|
||||
<span className={`text-gray-400`}>@{reaction.authorName}</span>
|
||||
<span>did</span>
|
||||
<span className={`text-lg`}>{reaction.emoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={`flex flex-col gap-4 mb-4 px-4`}>{items.map((item) => item.component)}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReactionItem({ reaction }: { reaction: PostReaction }) {
|
||||
return (
|
||||
<div className={`flex flex-col`}>
|
||||
<span className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(reaction.reactedOn)}</span>
|
||||
<div className={`flex flex-row items-baseline text-gray-400`}>
|
||||
<span>@{reaction.authorName}</span>
|
||||
<span>clicked</span>
|
||||
<span>{reaction.emoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentItem({ comment }: { comment: PostComment }) {
|
||||
return (
|
||||
<div className={`flex flex-col`}>
|
||||
<div className={`text-gray-400 text-xs -mb-0.5`}>{formatItemDate(comment.postedOn)}</div>
|
||||
<div className={`flex flex-row items-baseline text-gray-500`}>
|
||||
<span className={`text-gray-400`}>@{comment.author}</span>
|
||||
</div>
|
||||
<div className={`ml-1 text-gray-600`}>{comment.content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatItemDate(date: Temporal.Instant) {
|
||||
return date.toLocaleString('en-AU', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
|
|||
import { PostsService } from '../posts/postsService.ts'
|
||||
import { useUserStore } from '../../user/user.ts'
|
||||
import { MediaService } from '../../media/mediaService.ts'
|
||||
import NewPostWidget from '../../../components/NewPostWidget.tsx'
|
||||
import NewPostWidget from '../components/NewPostWidget.tsx'
|
||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||
import NavBar from '../../../components/NavBar.tsx'
|
||||
import AuthNavButtons from '../../auth/components/AuthNavButtons.tsx'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { PostsService } from '../posts/postsService.ts'
|
||||
import SingleColumnLayout from '../../../layouts/SingleColumnLayout.tsx'
|
||||
|
@ -11,6 +11,7 @@ import { usePostViewModel } from '../posts/usePostViewModel.ts'
|
|||
import { Temporal } from '@js-temporal/polyfill'
|
||||
import { useUserStore } from '../../user/user.ts'
|
||||
import { PostTimeline } from '../components/PostTimeline.tsx'
|
||||
import NewCommentWidget from '../components/NewCommentWidget.tsx'
|
||||
|
||||
interface PostPageProps {
|
||||
postsService: PostsService
|
||||
|
@ -24,11 +25,15 @@ export default function PostPage({ postsService }: PostPageProps) {
|
|||
const post = posts.at(0)
|
||||
const reactions = (post?.postId ? _reactions[post.postId] : []) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
const loadPost = useCallback(() => {
|
||||
if (!postId) return
|
||||
postsService.load(postId).then((post) => setPosts(post ? [post] : []))
|
||||
}, [postId, postsService, setPosts])
|
||||
|
||||
useEffect(() => {
|
||||
loadPost()
|
||||
}, [loadPost])
|
||||
|
||||
const onAddReaction = async (emoji: string) => {
|
||||
if (!username) return
|
||||
if (!post) return
|
||||
|
@ -46,6 +51,22 @@ export default function PostPage({ postsService }: PostPageProps) {
|
|||
removeReaction(post.postId, emoji, username)
|
||||
}
|
||||
|
||||
async function onSubmitComment(content: string) {
|
||||
if (!postId) return
|
||||
if (!content.trim()) return
|
||||
|
||||
try {
|
||||
setIsSubmittingComment(true)
|
||||
await postsService.addComment(postId, content)
|
||||
} finally {
|
||||
setIsSubmittingComment(false)
|
||||
}
|
||||
|
||||
loadPost()
|
||||
}
|
||||
|
||||
const [isSubmittingComment, setIsSubmittingComment] = useState(false)
|
||||
|
||||
return (
|
||||
<SingleColumnLayout
|
||||
navbar={
|
||||
|
@ -65,7 +86,8 @@ export default function PostPage({ postsService }: PostPageProps) {
|
|||
clearReaction={onClearReaction}
|
||||
hideViewButton={true}
|
||||
/>
|
||||
<PostTimeline reactions={reactions} />
|
||||
<PostTimeline reactions={reactions} comments={post.comments} />
|
||||
<NewCommentWidget onSubmit={onSubmitComment} isSubmitting={isSubmittingComment} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
@ -8,6 +8,12 @@ export interface PostReaction {
|
|||
reactedOn: Temporal.Instant
|
||||
}
|
||||
|
||||
export interface PostComment {
|
||||
author: string
|
||||
content: string
|
||||
postedOn: Temporal.Instant
|
||||
}
|
||||
|
||||
export class Post {
|
||||
[immerable] = true
|
||||
|
||||
|
@ -18,6 +24,7 @@ export class Post {
|
|||
public readonly authorName: string
|
||||
public readonly reactions: PostReaction[]
|
||||
public readonly possibleReactions: string[]
|
||||
public readonly comments: PostComment[]
|
||||
|
||||
constructor(
|
||||
postId: string,
|
||||
|
@ -25,8 +32,9 @@ export class Post {
|
|||
media: PostMedia[],
|
||||
createdAt: string | Temporal.Instant,
|
||||
authorName: string,
|
||||
reactions: PostReaction[] = [],
|
||||
possibleReactions: string[] = [],
|
||||
reactions: PostReaction[],
|
||||
possibleReactions: string[],
|
||||
comments: PostComment[],
|
||||
) {
|
||||
this.postId = postId
|
||||
this.content = content
|
||||
|
@ -35,6 +43,7 @@ export class Post {
|
|||
this.authorName = authorName
|
||||
this.reactions = reactions
|
||||
this.possibleReactions = possibleReactions
|
||||
this.comments = comments
|
||||
}
|
||||
|
||||
public static fromDto(dto: components['schemas']['PostDto']): Post {
|
||||
|
@ -46,6 +55,7 @@ export class Post {
|
|||
dto.author.username,
|
||||
dto.reactions.map((r) => ({ ...r, reactedOn: Temporal.Instant.from(r.reactedOn) })),
|
||||
dto.possibleReactions,
|
||||
dto.comments.map((c) => ({ ...c, postedOn: Temporal.Instant.from(c.postedOn) })),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Post } from './posts.ts'
|
||||
import { ApiClient } from '../../api/client.ts'
|
||||
import { useUserStore } from '../../user/user.ts'
|
||||
|
||||
export class PostsService {
|
||||
constructor(private readonly client: ApiClient) {}
|
||||
|
@ -78,6 +79,17 @@ export class PostsService {
|
|||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async addComment(postId: string, content: string): Promise<void> {
|
||||
const authorId = useUserStore.getState().user?.id
|
||||
if (!authorId) return
|
||||
|
||||
await this.client.POST('/posts/{postId}/comments', {
|
||||
params: { path: { postId } },
|
||||
body: { content, authorId },
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface CreatePostMedia {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { Post, PostMedia, PostReaction } from './posts.ts'
|
||||
import { Post, PostComment, PostMedia, PostReaction } from './posts.ts'
|
||||
import { Temporal } from '@js-temporal/polyfill'
|
||||
import { produce } from 'immer'
|
||||
|
||||
|
@ -10,6 +10,7 @@ export interface PostInfo {
|
|||
createdAt: Temporal.Instant
|
||||
media: PostMedia[]
|
||||
possibleReactions: string[]
|
||||
comments: PostComment[]
|
||||
}
|
||||
|
||||
type ReactionMap = Record<string, PostReaction[]>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue