2025-07-04 06:29:43 +02:00
import type {
NoteReactionWithAccounts ,
Status as StatusSchema ,
} from "@versia/client/schemas" ;
2025-06-15 23:50:34 +02:00
import * as VersiaEntities from "@versia/sdk/entities" ;
2025-07-04 06:29:43 +02:00
import { FederationRequester } from "@versia/sdk/http" ;
2025-06-15 23:50:34 +02:00
import type { NonTextContentFormatSchema } from "@versia/sdk/schemas" ;
import { config } from "@versia-server/config" ;
2025-03-30 22:10:33 +02:00
import { randomUUIDv7 } from "bun" ;
2024-04-17 06:09:21 +02:00
import {
and ,
desc ,
eq ,
2025-04-10 19:15:31 +02:00
type InferInsertModel ,
type InferSelectModel ,
2024-04-17 06:09:21 +02:00
inArray ,
2024-04-25 05:40:27 +02:00
isNotNull ,
2025-04-10 19:15:31 +02:00
type SQL ,
2024-05-06 09:16:33 +02:00
sql ,
2024-04-17 06:09:21 +02:00
} from "drizzle-orm" ;
import { htmlToText } from "html-to-text" ;
import { createRegExp , exactly , global } from "magic-regexp" ;
2025-11-21 08:31:02 +01:00
import type { z } from "zod" ;
2025-04-10 19:15:31 +02:00
import { mergeAndDeduplicate } from "@/lib.ts" ;
import { sanitizedHtmlStrip } from "@/sanitization" ;
2025-07-04 06:29:43 +02:00
import { versiaTextToHtml } from "../parsers.ts" ;
import { DeliveryJobType , deliveryQueue } from "../queues/delivery/queue.ts" ;
import { uuid } from "../regex.ts" ;
import { db } from "../tables/db.ts" ;
import {
EmojiToNote ,
Likes ,
MediasToNotes ,
Notes ,
NoteToMentions ,
Notifications ,
Users ,
} from "../tables/schema.ts" ;
2025-08-21 01:21:32 +02:00
import { Client } from "./application.ts" ;
2024-10-04 15:22:48 +02:00
import { BaseInterface } from "./base.ts" ;
2024-11-04 15:20:53 +01:00
import { Emoji } from "./emoji.ts" ;
2025-07-04 06:29:43 +02:00
import { Instance } from "./instance.ts" ;
import { Like } from "./like.ts" ;
2025-01-23 19:37:17 +01:00
import { Media } from "./media.ts" ;
2025-07-04 06:29:43 +02:00
import { Reaction } from "./reaction.ts" ;
2025-06-15 23:43:27 +02:00
import {
transformOutputToUserWithRelations ,
User ,
userRelations ,
} from "./user.ts" ;
/ * *
* Wrapper against the Status object to make it easier to work with
* @param query
* @returns
* /
const findManyNotes = async (
query : Parameters < typeof db.query.Notes.findMany > [ 0 ] ,
userId? : string ,
) : Promise < ( typeof Note . $type ) [ ] > = > {
const output = await db . query . Notes . findMany ( {
. . . query ,
with : {
. . . query ? . with ,
attachments : {
with : {
media : true ,
} ,
} ,
reactions : {
with : {
emoji : {
with : {
instance : true ,
media : true ,
} ,
} ,
} ,
} ,
emojis : {
with : {
emoji : {
with : {
instance : true ,
media : true ,
} ,
} ,
} ,
} ,
author : {
with : {
. . . userRelations ,
} ,
} ,
mentions : {
with : {
user : {
with : {
instance : true ,
} ,
} ,
} ,
} ,
reblog : {
with : {
attachments : {
with : {
media : true ,
} ,
} ,
reactions : {
with : {
emoji : {
with : {
instance : true ,
media : true ,
} ,
} ,
} ,
} ,
emojis : {
with : {
emoji : {
with : {
instance : true ,
media : true ,
} ,
} ,
} ,
} ,
likes : true ,
2025-08-21 01:21:32 +02:00
client : true ,
2025-06-15 23:43:27 +02:00
mentions : {
with : {
user : {
with : userRelations ,
} ,
} ,
} ,
author : {
with : {
. . . userRelations ,
} ,
} ,
} ,
extras : {
pinned : userId
? sql ` EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${ userId } ) ` . as (
"pinned" ,
)
: sql ` false ` . as ( "pinned" ) ,
reblogged : userId
? sql ` EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${ userId } AND "Notes"."reblogId" = "Notes_reblog".id) ` . as (
"reblogged" ,
)
: sql ` false ` . as ( "reblogged" ) ,
muted : userId
? sql ` EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${ userId } AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true) ` . as (
"muted" ,
)
: sql ` false ` . as ( "muted" ) ,
liked : userId
? sql ` EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${ userId } ) ` . as (
"liked" ,
)
: sql ` false ` . as ( "liked" ) ,
} ,
} ,
reply : true ,
quote : true ,
} ,
extras : {
pinned : userId
? sql ` EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${ userId } ) ` . as (
"pinned" ,
)
: sql ` false ` . as ( "pinned" ) ,
reblogged : userId
? sql ` EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${ userId } AND "Notes"."reblogId" = "Notes".id) ` . as (
"reblogged" ,
)
: sql ` false ` . as ( "reblogged" ) ,
muted : userId
? sql ` EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${ userId } AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true) ` . as (
"muted" ,
)
: sql ` false ` . as ( "muted" ) ,
liked : userId
? sql ` EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${ userId } ) ` . as (
"liked" ,
)
: sql ` false ` . as ( "liked" ) ,
. . . query ? . extras ,
} ,
} ) ;
return output . map ( ( post ) = > ( {
. . . post ,
author : transformOutputToUserWithRelations ( post . author ) ,
mentions : post.mentions.map ( ( mention ) = > ( {
. . . mention . user ,
endpoints : mention.user.endpoints ,
} ) ) ,
attachments : post.attachments.map ( ( attachment ) = > attachment . media ) ,
emojis : ( post . emojis ? ? [ ] ) . map ( ( emoji ) = > emoji . emoji ) ,
reblog : post.reblog && {
. . . post . reblog ,
author : transformOutputToUserWithRelations ( post . reblog . author ) ,
mentions : post.reblog.mentions.map ( ( mention ) = > ( {
. . . mention . user ,
endpoints : mention.user.endpoints ,
} ) ) ,
attachments : post.reblog.attachments.map (
( attachment ) = > attachment . media ,
) ,
emojis : ( post . reblog . emojis ? ? [ ] ) . map ( ( emoji ) = > emoji . emoji ) ,
pinned : Boolean ( post . reblog . pinned ) ,
reblogged : Boolean ( post . reblog . reblogged ) ,
muted : Boolean ( post . reblog . muted ) ,
liked : Boolean ( post . reblog . liked ) ,
} ,
pinned : Boolean ( post . pinned ) ,
reblogged : Boolean ( post . reblogged ) ,
muted : Boolean ( post . muted ) ,
liked : Boolean ( post . liked ) ,
} ) ) ;
} ;
2024-04-17 06:09:21 +02:00
2024-11-04 14:58:17 +01:00
type NoteType = InferSelectModel < typeof Notes > ;
type NoteTypeWithRelations = NoteType & {
author : typeof User . $type ;
mentions : ( InferSelectModel < typeof Users > & {
instance : typeof Instance . $type | null ;
} ) [ ] ;
2025-01-23 16:08:42 +01:00
attachments : ( typeof Media . $type ) [ ] ;
2024-11-04 14:58:17 +01:00
reblog : NoteTypeWithoutRecursiveRelations | null ;
emojis : ( typeof Emoji . $type ) [ ] ;
reply : NoteType | null ;
quote : NoteType | null ;
2025-08-21 01:21:32 +02:00
client : typeof Client . $type | null ;
2024-11-04 14:58:17 +01:00
pinned : boolean ;
reblogged : boolean ;
muted : boolean ;
liked : boolean ;
2025-05-25 16:11:56 +02:00
reactions : Omit < typeof Reaction. $ type , " note " | " author " > [ ] ;
2024-11-04 14:58:17 +01:00
} ;
export type NoteTypeWithoutRecursiveRelations = Omit <
NoteTypeWithRelations ,
"reply" | "quote" | "reblog"
> ;
2024-04-17 06:09:21 +02:00
/ * *
* Gives helpers to fetch notes from database in a nice format
* /
2024-11-04 14:58:17 +01:00
export class Note extends BaseInterface < typeof Notes , NoteTypeWithRelations > {
public static $type : NoteTypeWithRelations ;
public save ( ) : Promise < NoteTypeWithRelations > {
2024-06-13 02:45:07 +02:00
return this . update ( this . data ) ;
}
2024-10-24 17:20:00 +02:00
/ * *
* @param userRequestingNoteId Used to calculate visibility of the note
* /
2024-11-01 21:20:12 +01:00
public async reload ( userRequestingNoteId? : string ) : Promise < void > {
2024-10-24 17:20:00 +02:00
const reloaded = await Note . fromId ( this . data . id , userRequestingNoteId ) ;
2024-06-13 02:45:07 +02:00
if ( ! reloaded ) {
throw new Error ( "Failed to reload status" ) ;
}
this . data = reloaded . data ;
}
2024-06-13 10:52:03 +02:00
/ * *
* Insert a new note into the database
* @param data - The data to insert
* @param userRequestingNoteId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns The inserted note
* /
2024-06-13 02:45:07 +02:00
public static async insert (
data : InferInsertModel < typeof Notes > ,
userRequestingNoteId? : string ,
) : Promise < Note > {
const inserted = ( await db . insert ( Notes ) . values ( data ) . returning ( ) ) [ 0 ] ;
const note = await Note . fromId ( inserted . id , userRequestingNoteId ) ;
if ( ! note ) {
throw new Error ( "Failed to insert status" ) ;
}
2025-05-04 16:38:37 +02:00
// Update author's status count
await note . author . recalculateStatusCount ( ) ;
if ( note . data . replyId ) {
// Update the reply's reply count
await new Note (
note . data . reply as typeof Note . $type ,
) . recalculateReplyCount ( ) ;
}
2024-06-13 02:45:07 +02:00
return note ;
}
2024-04-17 06:09:21 +02:00
2024-06-13 10:52:03 +02:00
/ * *
* Fetch a note from the database by its ID
* @param id - The ID of the note to fetch
* @param userRequestingNoteId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns The fetched note
* /
2024-11-01 21:20:12 +01:00
public static async fromId (
2024-05-09 01:19:53 +02:00
id : string | null ,
2024-06-13 02:45:07 +02:00
userRequestingNoteId? : string ,
2024-05-09 01:19:53 +02:00
) : Promise < Note | null > {
2024-06-13 04:26:43 +02:00
if ( ! id ) {
return null ;
}
2024-04-17 06:09:21 +02:00
2024-06-13 02:45:07 +02:00
return await Note . fromSql (
eq ( Notes . id , id ) ,
undefined ,
userRequestingNoteId ,
) ;
2024-04-17 06:09:21 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Fetch multiple notes from the database by their IDs
* @param ids - The IDs of the notes to fetch
* @param userRequestingNoteId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns The fetched notes
* /
2024-11-01 21:20:12 +01:00
public static async fromIds (
2024-06-13 02:45:07 +02:00
ids : string [ ] ,
userRequestingNoteId? : string ,
) : Promise < Note [ ] > {
2024-05-09 01:19:53 +02:00
return await Note . manyFromSql (
inArray ( Notes . id , ids ) ,
undefined ,
undefined ,
undefined ,
2024-06-13 02:45:07 +02:00
userRequestingNoteId ,
2024-05-09 01:19:53 +02:00
) ;
2024-04-17 06:09:21 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Fetch a note from the database by a SQL query
* @param sql - The SQL query to fetch the note with
* @param orderBy - The SQL query to order the results by
* @param userId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns The fetched note
* /
2024-11-01 21:20:12 +01:00
public static async fromSql (
2024-04-17 06:09:21 +02:00
sql : SQL < unknown > | undefined ,
2024-04-17 08:36:01 +02:00
orderBy : SQL < unknown > | undefined = desc ( Notes . id ) ,
2024-05-09 01:19:53 +02:00
userId? : string ,
2024-06-13 10:52:03 +02:00
) : Promise < Note | null > {
2024-05-09 01:19:53 +02:00
const found = await findManyNotes (
{
where : sql ,
orderBy ,
limit : 1 ,
} ,
userId ,
) ;
2024-04-17 06:09:21 +02:00
2024-06-13 04:26:43 +02:00
if ( ! found [ 0 ] ) {
return null ;
}
2024-05-08 23:51:47 +02:00
return new Note ( found [ 0 ] ) ;
2024-04-17 06:09:21 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Fetch multiple notes from the database by a SQL query
* @param sql - The SQL query to fetch the notes with
* @param orderBy - The SQL query to order the results by
* @param limit - The maximum number of notes to fetch
* @param offset - The number of notes to skip
* @param userId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns - The fetched notes
* /
2024-11-01 21:20:12 +01:00
public static async manyFromSql (
2024-04-17 06:09:21 +02:00
sql : SQL < unknown > | undefined ,
2024-04-17 08:36:01 +02:00
orderBy : SQL < unknown > | undefined = desc ( Notes . id ) ,
2024-04-17 06:09:21 +02:00
limit? : number ,
offset? : number ,
2024-05-09 01:19:53 +02:00
userId? : string ,
2024-06-13 10:52:03 +02:00
) : Promise < Note [ ] > {
2024-05-09 01:19:53 +02:00
const found = await findManyNotes (
{
where : sql ,
orderBy ,
limit ,
offset ,
} ,
userId ,
) ;
2024-04-17 06:09:21 +02:00
return found . map ( ( s ) = > new Note ( s ) ) ;
}
2024-11-02 00:43:33 +01:00
public get id ( ) : string {
2024-06-13 02:45:07 +02:00
return this . data . id ;
2024-04-25 05:40:27 +02:00
}
2024-11-01 21:20:12 +01:00
public async federateToUsers ( ) : Promise < void > {
2024-07-26 18:51:39 +02:00
const users = await this . getUsersToFederateTo ( ) ;
2024-11-25 11:29:48 +01:00
await deliveryQueue . addBulk (
users . map ( ( user ) = > ( {
data : {
2025-04-08 16:01:10 +02:00
entity : this.toVersia ( ) . toJSON ( ) ,
2024-11-25 11:29:48 +01:00
recipientId : user.id ,
senderId : this.author.id ,
} ,
name : DeliveryJobType.FederateEntity ,
} ) ) ,
) ;
2024-07-26 18:51:39 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Fetch the users that should be federated to for this note
*
* This includes :
* - Users mentioned in the note
* - Users that can see the note
* @returns The users that should be federated to
* /
2024-11-01 21:20:12 +01:00
public async getUsersToFederateTo ( ) : Promise < User [ ] > {
2024-04-17 06:09:21 +02:00
// Mentioned users
const mentionedUsers =
2024-06-13 02:45:07 +02:00
this . data . mentions . length > 0
2024-04-25 05:40:27 +02:00
? await User . manyFromSql (
and (
isNotNull ( Users . instanceId ) ,
inArray (
Users . id ,
2024-06-13 02:45:07 +02:00
this . data . mentions . map ( ( mention ) = > mention . id ) ,
2024-04-17 06:09:21 +02:00
) ,
2024-04-25 05:40:27 +02:00
) ,
)
2024-04-17 06:09:21 +02:00
: [ ] ;
2024-04-25 05:40:27 +02:00
const usersThatCanSeePost = await User . manyFromSql (
isNotNull ( Users . instanceId ) ,
undefined ,
undefined ,
undefined ,
{
with : {
relationships : {
2025-05-26 19:00:24 +02:00
where : ( relationship ) : SQL | undefined = >
2024-04-25 05:40:27 +02:00
and (
2024-07-26 19:21:03 +02:00
eq ( relationship . subjectId , this . data . authorId ) ,
2024-04-25 05:40:27 +02:00
eq ( relationship . following , true ) ,
) ,
} ,
2024-04-17 06:09:21 +02:00
} ,
} ,
2024-04-25 05:40:27 +02:00
) ;
2024-04-17 06:09:21 +02:00
2024-12-09 13:50:46 +01:00
const fusedUsers = mergeAndDeduplicate (
mentionedUsers ,
usersThatCanSeePost ,
2024-04-17 06:09:21 +02:00
) ;
2024-12-09 13:50:46 +01:00
return fusedUsers ;
2024-04-17 06:09:21 +02:00
}
2024-11-02 00:43:33 +01:00
public get author ( ) : User {
2024-06-13 02:45:07 +02:00
return new User ( this . data . author ) ;
2024-04-17 06:09:21 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Get the number of notes in the database ( excluding remote notes )
* @returns The number of notes in the database
* /
2024-11-01 21:20:12 +01:00
public static async getCount ( ) : Promise < number > {
2024-10-11 15:46:05 +02:00
return await db . $count (
Notes ,
sql ` EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${ Notes . authorId } AND "Users"."instanceId" IS NULL) ` ,
) ;
2024-05-06 09:16:33 +02:00
}
2025-07-04 06:29:43 +02:00
/ * *
* Reblog a note .
*
* If the note is already reblogged , it will return the existing reblog . Also creates a notification for the author of the note .
* @param reblogger The user reblogging the note
* @param visibility The visibility of the reblog
* @param uri The URI of the reblog , if it is remote
* @returns The reblog object created or the existing reblog
* /
public async reblog (
reblogger : User ,
visibility : z.infer < typeof StatusSchema.shape.visibility > ,
uri? : URL ,
) : Promise < Note > {
const existingReblog = await Note . fromSql (
and ( eq ( Notes . authorId , reblogger . id ) , eq ( Notes . reblogId , this . id ) ) ,
undefined ,
reblogger . id ,
) ;
if ( existingReblog ) {
return existingReblog ;
}
const newReblog = await Note . insert ( {
id : randomUUIDv7 ( ) ,
authorId : reblogger.id ,
reblogId : this.id ,
visibility ,
sensitive : false ,
updatedAt : new Date ( ) . toISOString ( ) ,
2025-08-21 01:21:32 +02:00
clientId : null ,
2025-07-04 06:29:43 +02:00
uri : uri?.href ,
} ) ;
await this . recalculateReblogCount ( ) ;
// Refetch the note *again* to get the proper value of .reblogged
await newReblog . reload ( reblogger ? . id ) ;
if ( ! newReblog ) {
throw new Error ( "Failed to reblog" ) ;
}
if ( this . author . local ) {
// Notify the user that their post has been reblogged
await this . author . notify ( "reblog" , reblogger , newReblog ) ;
}
if ( reblogger . local ) {
const federatedUsers = await reblogger . federateToFollowers (
newReblog . toVersiaShare ( ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await reblogger . federateToUser (
newReblog . toVersiaShare ( ) ,
this . author ,
) ;
}
}
return newReblog ;
}
/ * *
* Unreblog a note .
*
* If the note is not reblogged , it will return without doing anything . Also removes any notifications for this reblog .
* @param unreblogger The user unreblogging the note
* @returns
* /
public async unreblog ( unreblogger : User ) : Promise < void > {
const reblogToDelete = await Note . fromSql (
and (
eq ( Notes . authorId , unreblogger . id ) ,
eq ( Notes . reblogId , this . id ) ,
) ,
undefined ,
unreblogger . id ,
) ;
if ( ! reblogToDelete ) {
return ;
}
await reblogToDelete . delete ( ) ;
await this . recalculateReblogCount ( ) ;
if ( this . author . local ) {
// Remove any eventual notifications for this reblog
await db
. delete ( Notifications )
. where (
and (
eq ( Notifications . accountId , this . id ) ,
eq ( Notifications . type , "reblog" ) ,
eq ( Notifications . notifiedId , unreblogger . id ) ,
eq ( Notifications . noteId , this . id ) ,
) ,
) ;
}
if ( this . local ) {
const federatedUsers = await unreblogger . federateToFollowers (
reblogToDelete . toVersiaUnshare ( ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await unreblogger . federateToUser (
reblogToDelete . toVersiaUnshare ( ) ,
this . author ,
) ;
}
}
}
/ * *
* Like a note .
*
* If the note is already liked , it will return the existing like . Also creates a notification for the author of the note .
* @param liker The user liking the note
* @param uri The URI of the like , if it is remote
* @returns The like object created or the existing like
* /
public async like ( liker : User , uri? : URL ) : Promise < Like > {
// Check if the user has already liked the note
const existingLike = await Like . fromSql (
and ( eq ( Likes . likerId , liker . id ) , eq ( Likes . likedId , this . id ) ) ,
) ;
if ( existingLike ) {
return existingLike ;
}
const newLike = await Like . insert ( {
id : randomUUIDv7 ( ) ,
likerId : liker.id ,
likedId : this.id ,
uri : uri?.href ,
} ) ;
await this . recalculateLikeCount ( ) ;
if ( this . author . local ) {
// Notify the user that their post has been favourited
await this . author . notify ( "favourite" , liker , this ) ;
}
if ( liker . local ) {
const federatedUsers = await liker . federateToFollowers (
newLike . toVersia ( ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await liker . federateToUser ( newLike . toVersia ( ) , this . author ) ;
}
}
return newLike ;
}
/ * *
* Unlike a note .
*
* If the note is not liked , it will return without doing anything . Also removes any notifications for this like .
* @param unliker The user unliking the note
* @returns
* /
public async unlike ( unliker : User ) : Promise < void > {
const likeToDelete = await Like . fromSql (
and ( eq ( Likes . likerId , unliker . id ) , eq ( Likes . likedId , this . id ) ) ,
) ;
if ( ! likeToDelete ) {
return ;
}
await likeToDelete . delete ( ) ;
await this . recalculateLikeCount ( ) ;
if ( this . author . local ) {
// Remove any eventual notifications for this like
await likeToDelete . clearRelatedNotifications ( ) ;
}
if ( unliker . local ) {
const federatedUsers = await unliker . federateToFollowers (
likeToDelete . unlikeToVersia ( unliker ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await unliker . federateToUser (
likeToDelete . unlikeToVersia ( unliker ) ,
this . author ,
) ;
}
}
}
/ * *
* Add an emoji reaction to a note
* @param reacter - The author of the reaction
* @param emoji - The emoji to react with ( Emoji object for custom emojis , or Unicode emoji )
* @returns The created reaction
* /
public async react ( reacter : User , emoji : Emoji | string ) : Promise < void > {
const existingReaction = await Reaction . fromEmoji ( emoji , reacter , this ) ;
if ( existingReaction ) {
return ; // Reaction already exists, don't create duplicate
}
// Create the reaction
const reaction = await Reaction . insert ( {
id : randomUUIDv7 ( ) ,
authorId : reacter.id ,
noteId : this.id ,
emojiText : emoji instanceof Emoji ? null : emoji ,
emojiId : emoji instanceof Emoji ? emoji.id : null ,
} ) ;
await this . reload ( reacter . id ) ;
if ( this . author . local ) {
// Notify the user that their post has been reacted to
await this . author . notify ( "reaction" , reacter , this ) ;
}
if ( reacter . local ) {
const federatedUsers = await reacter . federateToFollowers (
reaction . toVersia ( ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await reacter . federateToUser ( reaction . toVersia ( ) , this . author ) ;
}
}
}
/ * *
* Remove an emoji reaction from a note
* @param unreacter - The author of the reaction
* @param emoji - The emoji to remove reaction for ( Emoji object for custom emojis , or Unicode emoji )
* /
public async unreact (
unreacter : User ,
emoji : Emoji | string ,
) : Promise < void > {
const reactionToDelete = await Reaction . fromEmoji (
emoji ,
unreacter ,
this ,
) ;
if ( ! reactionToDelete ) {
return ; // Reaction doesn't exist, nothing to delete
}
await reactionToDelete . delete ( ) ;
if ( this . author . local ) {
// Remove any eventual notifications for this reaction
await db
. delete ( Notifications )
. where (
and (
eq ( Notifications . accountId , unreacter . id ) ,
eq ( Notifications . type , "reaction" ) ,
eq ( Notifications . notifiedId , this . data . authorId ) ,
eq ( Notifications . noteId , this . id ) ,
) ,
) ;
}
if ( unreacter . local ) {
const federatedUsers = await unreacter . federateToFollowers (
reactionToDelete . toVersiaUnreact ( ) ,
) ;
if (
this . remote &&
! federatedUsers . find ( ( u ) = > u . id === this . author . id )
) {
await unreacter . federateToUser (
reactionToDelete . toVersiaUnreact ( ) ,
this . author ,
) ;
}
}
}
2024-06-13 10:52:03 +02:00
/ * *
* Get the children of this note ( replies )
* @param userId - The ID of the user requesting the note ( used to check visibility of the note )
* @returns The children of this note
* /
private async getReplyChildren ( userId? : string ) : Promise < Note [ ] > {
2024-05-09 01:19:53 +02:00
return await Note . manyFromSql (
2024-06-13 02:45:07 +02:00
eq ( Notes . replyId , this . data . id ) ,
2024-05-09 01:19:53 +02:00
undefined ,
undefined ,
undefined ,
userId ,
) ;
2024-04-17 08:36:01 +02:00
}
2025-04-08 18:13:30 +02:00
public get remote ( ) : boolean {
return this . author . remote ;
2024-06-06 09:04:52 +02:00
}
2025-04-08 18:13:30 +02:00
public get local ( ) : boolean {
return this . author . local ;
2024-06-13 06:16:59 +02:00
}
2025-05-04 16:38:37 +02:00
public async recalculateReblogCount ( ) : Promise < void > {
const reblogCount = await db . $count ( Notes , eq ( Notes . reblogId , this . id ) ) ;
await this . update ( { reblogCount } ) ;
}
public async recalculateLikeCount ( ) : Promise < void > {
const likeCount = await db . $count ( Likes , eq ( Likes . likedId , this . id ) ) ;
await this . update ( { likeCount } ) ;
}
public async recalculateReplyCount ( ) : Promise < void > {
const replyCount = await db . $count ( Notes , eq ( Notes . replyId , this . id ) ) ;
await this . update ( { replyCount } ) ;
}
2024-06-13 10:52:03 +02:00
/ * *
* Updates the emojis associated with this note in the database
*
* Deletes all existing emojis associated with this note , then replaces them with the provided emojis .
* @param emojis - The emojis to associate with this note
* /
2024-12-09 13:36:15 +01:00
public async updateEmojis ( emojis : Emoji [ ] ) : Promise < void > {
if ( emojis . length === 0 ) {
return ;
}
2024-04-17 06:09:21 +02:00
// Connect emojis
await db
2024-04-17 08:36:01 +02:00
. delete ( EmojiToNote )
2024-06-13 02:45:07 +02:00
. where ( eq ( EmojiToNote . noteId , this . data . id ) ) ;
2024-12-09 13:36:15 +01:00
await db . insert ( EmojiToNote ) . values (
2024-12-09 13:50:46 +01:00
emojis . map ( ( emoji ) = > ( {
2024-12-09 13:36:15 +01:00
emojiId : emoji.id ,
noteId : this.data.id ,
} ) ) ,
) ;
2024-06-13 06:16:59 +02:00
}
2024-04-17 06:09:21 +02:00
2024-06-13 10:52:03 +02:00
/ * *
* Updates the mentions associated with this note in the database
*
* Deletes all existing mentions associated with this note , then replaces them with the provided mentions .
* @param mentions - The mentions to associate with this note
* /
2024-12-09 13:36:15 +01:00
public async updateMentions ( mentions : User [ ] ) : Promise < void > {
if ( mentions . length === 0 ) {
return ;
}
2024-04-17 06:09:21 +02:00
// Connect mentions
await db
2024-04-17 08:36:01 +02:00
. delete ( NoteToMentions )
2024-06-13 02:45:07 +02:00
. where ( eq ( NoteToMentions . noteId , this . data . id ) ) ;
2024-12-09 13:36:15 +01:00
await db . insert ( NoteToMentions ) . values (
mentions . map ( ( mention ) = > ( {
noteId : this.data.id ,
userId : mention.id ,
} ) ) ,
) ;
2024-06-13 06:16:59 +02:00
}
2024-04-17 06:09:21 +02:00
2024-06-13 10:52:03 +02:00
/ * *
* Updates the attachments associated with this note in the database
*
* Deletes all existing attachments associated with this note , then replaces them with the provided attachments .
* @param mediaAttachments - The IDs of the attachments to associate with this note
* /
2025-01-23 16:08:42 +01:00
public async updateAttachments ( mediaAttachments : Media [ ] ) : Promise < void > {
2024-12-09 13:36:15 +01:00
if ( mediaAttachments . length === 0 ) {
return ;
}
// Remove old attachments
2024-06-13 06:16:59 +02:00
await db
2025-01-23 20:36:09 +01:00
. delete ( MediasToNotes )
. where ( eq ( MediasToNotes . noteId , this . data . id ) ) ;
await db . insert ( MediasToNotes ) . values (
mediaAttachments . map ( ( media ) = > ( {
2024-12-09 13:36:15 +01:00
noteId : this.data.id ,
2025-01-23 20:36:09 +01:00
mediaId : media.id ,
} ) ) ,
) ;
2024-04-17 06:09:21 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Resolve a note from a URI
* @param uri - The URI of the note to resolve
* @returns The resolved note
* /
2025-02-01 16:32:18 +01:00
public static async resolve ( uri : URL ) : Promise < Note | null > {
2024-06-06 09:04:52 +02:00
// Check if note not already in database
2025-04-08 18:13:30 +02:00
const foundNote = await Note . fromSql ( eq ( Notes . uri , uri . href ) ) ;
2024-06-06 09:04:52 +02:00
2024-06-13 04:26:43 +02:00
if ( foundNote ) {
return foundNote ;
}
2024-06-06 09:04:52 +02:00
// Check if URI is of a local note
2025-02-01 16:32:18 +01:00
if ( uri . origin === config . http . base_url . origin ) {
2025-06-15 23:43:27 +02:00
const noteUuid = uri . pathname . match ( uuid ) ;
2024-06-06 09:04:52 +02:00
2025-06-15 23:43:27 +02:00
if ( ! noteUuid ? . [ 0 ] ) {
2024-06-06 09:04:52 +02:00
throw new Error (
` URI ${ uri } is of a local note, but it could not be parsed ` ,
) ;
}
2025-06-15 23:43:27 +02:00
return await Note . fromId ( noteUuid [ 0 ] ) ;
2024-06-06 09:04:52 +02:00
}
2025-04-08 18:13:30 +02:00
return Note . fromVersia ( uri ) ;
2024-06-13 06:16:59 +02:00
}
2024-06-06 09:04:52 +02:00
2025-04-08 18:13:30 +02:00
/ * *
* Takes a Versia Note representation , and serializes it to the database .
*
* If the note already exists , it will update it .
2025-11-21 07:26:35 +01:00
* @param versiaNote - URL or Versia Note representation
2025-04-08 18:13:30 +02:00
* /
public static async fromVersia (
versiaNote : VersiaEntities.Note | URL ,
) : Promise < Note > {
if ( versiaNote instanceof URL ) {
// No bridge support for notes yet
2025-07-04 06:29:43 +02:00
const note = await new FederationRequester (
config . instance . keys . private ,
config . http . base_url ,
) . fetchEntity ( versiaNote , VersiaEntities . Note ) ;
2025-04-08 18:13:30 +02:00
return Note . fromVersia ( note ) ;
2025-04-08 16:01:10 +02:00
}
2025-04-08 18:13:30 +02:00
const {
author : authorUrl ,
created_at ,
uri ,
extensions ,
group ,
is_sensitive ,
mentions : noteMentions ,
quotes ,
replies_to ,
subject ,
} = versiaNote . data ;
2025-04-16 16:35:17 +02:00
const instance = await Instance . resolve ( new URL ( authorUrl ) ) ;
const author = await User . resolve ( new URL ( authorUrl ) ) ;
2025-04-08 18:13:30 +02:00
if ( ! author ) {
throw new Error ( "Entity author could not be resolved" ) ;
2024-06-06 09:04:52 +02:00
}
2025-04-16 16:35:17 +02:00
const existingNote = await Note . fromSql ( eq ( Notes . uri , uri ) ) ;
2024-06-06 09:04:52 +02:00
2025-04-08 18:13:30 +02:00
const note =
existingNote ? ?
( await Note . insert ( {
id : randomUUIDv7 ( ) ,
authorId : author.id ,
visibility : "public" ,
2025-04-16 16:35:17 +02:00
uri ,
2025-04-08 18:13:30 +02:00
createdAt : new Date ( created_at ) . toISOString ( ) ,
} ) ) ;
2024-06-06 09:04:52 +02:00
2025-04-08 18:13:30 +02:00
const attachments = await Promise . all (
versiaNote . attachments . map ( ( a ) = > Media . fromVersia ( a ) ) ,
) ;
2024-06-06 09:04:52 +02:00
2025-04-08 18:13:30 +02:00
const emojis = await Promise . all (
2025-05-28 02:59:26 +02:00
extensions ? . [ "pub.versia:custom_emojis" ] ? . emojis
. filter (
( e ) = >
! config . validation . filters . emoji_shortcode . some (
( filter ) = > filter . test ( e . name ) ,
) ,
)
. map ( ( emoji ) = > Emoji . fetchFromRemote ( emoji , instance ) ) ? ? [ ] ,
2025-04-08 18:13:30 +02:00
) ;
2024-08-26 19:06:49 +02:00
2025-04-08 18:13:30 +02:00
const mentions = (
await Promise . all (
2025-04-16 16:35:17 +02:00
noteMentions ? . map ( ( mention ) = >
User . resolve ( new URL ( mention ) ) ,
) ? ? [ ] ,
2025-04-08 18:13:30 +02:00
)
) . filter ( ( m ) = > m !== null ) ;
2024-08-26 19:06:49 +02:00
2025-04-08 18:13:30 +02:00
// TODO: Implement groups
2025-04-16 16:35:17 +02:00
const visibility =
! group || URL . canParse ( group )
? "direct"
: ( group as "public" | "followers" | "unlisted" ) ;
const reply = replies_to
? await Note . resolve ( new URL ( replies_to ) )
: null ;
const quote = quotes ? await Note . resolve ( new URL ( quotes ) ) : null ;
2025-04-08 18:13:30 +02:00
const spoiler = subject ? await sanitizedHtmlStrip ( subject ) : undefined ;
await note . update ( {
content : versiaNote.content
2025-06-15 23:43:27 +02:00
? await versiaTextToHtml ( versiaNote . content , mentions )
2024-06-06 09:04:52 +02:00
: undefined ,
2025-04-08 18:13:30 +02:00
contentSource : versiaNote.content
? versiaNote . content . data [ "text/plain" ] ? . content ||
versiaNote . content . data [ "text/markdown" ] ? . content
2024-06-06 09:04:52 +02:00
: undefined ,
2025-04-08 18:13:30 +02:00
contentType : "text/html" ,
visibility : visibility === "followers" ? "private" : visibility ,
sensitive : is_sensitive ? ? false ,
spoilerText : spoiler ,
replyId : reply?.id ,
quotingId : quote?.id ,
} ) ;
2024-06-13 06:16:59 +02:00
2025-04-08 18:13:30 +02:00
// Emojis, mentions, and attachments are stored in a different table, so update them there too
await note . updateEmojis ( emojis ) ;
await note . updateMentions ( mentions ) ;
await note . updateAttachments ( attachments ) ;
2024-06-06 09:04:52 +02:00
2025-04-08 18:13:30 +02:00
await note . reload ( author . id ) ;
2024-06-13 06:16:59 +02:00
2025-04-08 18:13:30 +02:00
// Send notifications for mentioned local users
for ( const mentioned of mentions ) {
if ( mentioned . local ) {
await mentioned . notify ( "mention" , author , note ) ;
}
2024-06-06 09:04:52 +02:00
}
2025-04-08 18:13:30 +02:00
return note ;
2024-06-06 09:04:52 +02:00
}
2025-05-04 16:38:37 +02:00
public async delete ( ) : Promise < void > {
await db . delete ( Notes ) . where ( eq ( Notes . id , this . id ) ) ;
// Update author's status count
await this . author . recalculateStatusCount ( ) ;
2024-04-17 06:09:21 +02:00
}
2024-11-01 21:20:12 +01:00
public async update (
2024-11-04 14:58:17 +01:00
newStatus : Partial < NoteTypeWithRelations > ,
) : Promise < NoteTypeWithRelations > {
2024-06-13 02:45:07 +02:00
await db . update ( Notes ) . set ( newStatus ) . where ( eq ( Notes . id , this . data . id ) ) ;
const updated = await Note . fromId ( this . data . id ) ;
2024-04-17 06:09:21 +02:00
2024-06-13 02:45:07 +02:00
if ( ! updated ) {
throw new Error ( "Failed to update status" ) ;
}
return updated . data ;
2024-04-17 06:09:21 +02:00
}
/ * *
* Returns whether this status is viewable by a user .
* @param user The user to check .
* @returns Whether this status is viewable by the user .
* /
2024-11-01 21:20:12 +01:00
public async isViewableByUser ( user : User | null ) : Promise < boolean > {
2024-06-13 04:26:43 +02:00
if ( this . author . id === user ? . id ) {
return true ;
}
if ( this . data . visibility === "public" ) {
return true ;
}
if ( this . data . visibility === "unlisted" ) {
return true ;
}
2024-06-13 02:45:07 +02:00
if ( this . data . visibility === "private" ) {
2024-04-17 06:09:21 +02:00
return user
2024-06-13 10:52:03 +02:00
? ! ! ( await db . query . Relationships . findFirst ( {
2025-05-26 19:00:24 +02:00
where : ( relationship ) : SQL | undefined = >
2024-04-17 06:09:21 +02:00
and (
eq ( relationship . ownerId , user ? . id ) ,
2024-04-17 08:36:01 +02:00
eq ( relationship . subjectId , Notes . authorId ) ,
2024-04-17 06:09:21 +02:00
eq ( relationship . following , true ) ,
) ,
2024-06-13 10:52:03 +02:00
} ) )
2024-04-17 06:09:21 +02:00
: false ;
}
return (
2024-06-13 10:52:03 +02:00
! ! user &&
! ! this . data . mentions . find ( ( mention ) = > mention . id === user . id )
2024-04-17 06:09:21 +02:00
) ;
}
2024-06-13 10:52:03 +02:00
/ * *
* Convert a note to the Mastodon API format
* @param userFetching - The user fetching the note ( used to check if the note is favourite and such )
* @returns The note in the Mastodon API format
* /
2025-02-05 22:49:07 +01:00
public async toApi (
userFetching? : User | null ,
2025-07-04 06:29:43 +02:00
) : Promise < z.infer < typeof StatusSchema > > {
2024-06-13 02:45:07 +02:00
const data = this . data ;
2024-05-08 23:51:47 +02:00
2024-04-17 06:09:21 +02:00
// Convert mentions of local users from @username@host to @username
const mentionedLocalUsers = data . mentions . filter (
( mention ) = > mention . instanceId === null ,
) ;
2024-11-22 15:06:46 +01:00
let replacedContent = data . content ;
2024-04-17 06:09:21 +02:00
for ( const mention of mentionedLocalUsers ) {
replacedContent = replacedContent . replace (
createRegExp (
exactly (
2025-02-13 01:31:15 +01:00
` @ ${ mention . username } @ ${ config . http . base_url . host } ` ,
2024-04-17 06:09:21 +02:00
) ,
[ global ] ,
) ,
` @ ${ mention . username } ` ,
) ;
}
2025-05-26 15:13:56 +02:00
const reactions = this . getReactions ( userFetching ? ? undefined ) . map (
// Remove account_ids
( r ) = > ( {
. . . r ,
account_ids : undefined ,
} ) ,
) ;
const emojis = data . emojis . concat (
data . reactions . map ( ( r ) = > r . emoji ) . filter ( ( v ) = > v !== null ) ,
) ;
2024-04-17 06:09:21 +02:00
return {
id : data.id ,
2024-04-17 08:36:01 +02:00
in_reply_to_id : data.replyId || null ,
in_reply_to_account_id : data.reply?.authorId || null ,
2024-06-13 04:26:43 +02:00
account : this.author.toApi ( userFetching ? . id === data . authorId ) ,
2024-04-17 06:09:21 +02:00
created_at : new Date ( data . createdAt ) . toISOString ( ) ,
2025-08-21 01:21:32 +02:00
application : data.client
? new Client ( data . client ) . toApi ( )
2025-02-05 22:49:07 +01:00
: undefined ,
2024-04-17 06:09:21 +02:00
card : null ,
content : replacedContent ,
2025-05-26 15:13:56 +02:00
emojis : emojis.map ( ( emoji ) = > new Emoji ( emoji ) . toApi ( ) ) ,
2024-05-09 01:19:53 +02:00
favourited : data.liked ,
2024-05-08 22:57:42 +02:00
favourites_count : data.likeCount ,
2025-02-12 23:25:22 +01:00
media_attachments : ( data . attachments ? ? [ ] ) . map ( ( a ) = >
new Media ( a ) . toApi ( ) ,
2024-04-17 06:09:21 +02:00
) ,
2024-04-25 05:40:27 +02:00
mentions : data.mentions.map ( ( mention ) = > ( {
id : mention.id ,
acct : User.getAcct (
mention . instanceId === null ,
mention . username ,
mention . instance ? . baseUrl ,
) ,
2025-02-01 16:32:18 +01:00
url : User.getUri (
mention . id ,
mention . uri ? new URL ( mention . uri ) : null ,
) . toString ( ) ,
2024-04-25 05:40:27 +02:00
username : mention.username ,
} ) ) ,
2024-04-17 06:09:21 +02:00
language : null ,
2024-05-09 01:19:53 +02:00
muted : data.muted ,
pinned : data.pinned ,
2024-04-17 06:09:21 +02:00
// TODO: Add polls
poll : null ,
2024-04-28 08:15:08 +02:00
reblog : data.reblog
2024-11-04 14:58:17 +01:00
? await new Note ( data . reblog as NoteTypeWithRelations ) . toApi (
2024-06-13 02:45:07 +02:00
userFetching ,
)
2024-04-28 08:15:08 +02:00
: null ,
2024-05-09 01:19:53 +02:00
reblogged : data.reblogged ,
2024-04-17 06:09:21 +02:00
reblogs_count : data.reblogCount ,
replies_count : data.replyCount ,
sensitive : data.sensitive ,
spoiler_text : data.spoilerText ,
tags : [ ] ,
2025-02-01 16:32:18 +01:00
uri : data.uri || this . getUri ( ) . toString ( ) ,
2025-02-12 23:25:22 +01:00
visibility : data.visibility ,
2025-02-01 16:32:18 +01:00
url : data.uri || this . getMastoUri ( ) . toString ( ) ,
2024-04-17 06:09:21 +02:00
bookmarked : false ,
2024-04-28 08:15:08 +02:00
quote : data.quotingId
2024-09-14 17:32:32 +02:00
? ( ( await Note . fromId ( data . quotingId , userFetching ? . id ) . then (
2024-06-13 04:26:43 +02:00
( n ) = > n ? . toApi ( userFetching ) ,
2024-09-14 17:32:32 +02:00
) ) ? ? null )
2024-04-28 08:15:08 +02:00
: null ,
2024-06-29 08:36:15 +02:00
edited_at : data.updatedAt
? new Date ( data . updatedAt ) . toISOString ( )
: null ,
2025-05-26 15:13:56 +02:00
reactions ,
2025-03-22 18:04:47 +01:00
text : data.contentSource ,
2024-04-17 06:09:21 +02:00
} ;
}
2025-02-01 16:32:18 +01:00
public getUri ( ) : URL {
2025-02-17 23:31:39 +01:00
return this . data . uri
? new URL ( this . data . uri )
: new URL ( ` /notes/ ${ this . id } ` , config . http . base_url ) ;
2024-04-25 05:40:27 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
* Get the frontend URI of this note
* @returns The frontend URI of this note
* /
2025-02-01 16:32:18 +01:00
public getMastoUri ( ) : URL {
2024-06-10 03:17:03 +02:00
return new URL (
2024-06-13 02:45:07 +02:00
` /@ ${ this . author . data . username } / ${ this . id } ` ,
2024-06-10 03:17:03 +02:00
config . http . base_url ,
2025-02-01 16:32:18 +01:00
) ;
2024-04-17 06:09:21 +02:00
}
2025-04-08 16:01:10 +02:00
public deleteToVersia ( ) : VersiaEntities . Delete {
2024-08-26 19:06:49 +02:00
const id = crypto . randomUUID ( ) ;
2025-04-08 16:01:10 +02:00
return new VersiaEntities . Delete ( {
2024-08-26 19:06:49 +02:00
type : "Delete" ,
id ,
2025-04-16 16:35:17 +02:00
author : this.author.uri.href ,
2024-08-26 19:06:49 +02:00
deleted_type : "Note" ,
2025-04-16 16:35:17 +02:00
deleted : this.getUri ( ) . href ,
2024-08-26 19:06:49 +02:00
created_at : new Date ( ) . toISOString ( ) ,
2025-04-08 16:01:10 +02:00
} ) ;
2024-08-26 19:06:49 +02:00
}
2024-06-13 10:52:03 +02:00
/ * *
2024-08-19 15:16:01 +02:00
* Convert a note to the Versia format
* @returns The note in the Versia format
2024-06-13 10:52:03 +02:00
* /
2025-04-08 16:01:10 +02:00
public toVersia ( ) : VersiaEntities . Note {
2024-06-13 02:45:07 +02:00
const status = this . data ;
2025-04-08 16:01:10 +02:00
return new VersiaEntities . Note ( {
2024-04-17 06:09:21 +02:00
type : "Note" ,
created_at : new Date ( status . createdAt ) . toISOString ( ) ,
id : status.id ,
2025-04-16 16:35:17 +02:00
author : this.author.uri.href ,
uri : this.getUri ( ) . href ,
2024-04-17 06:09:21 +02:00
content : {
"text/html" : {
content : status.content ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-17 06:09:21 +02:00
} ,
"text/plain" : {
content : htmlToText ( status . content ) ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-17 06:09:21 +02:00
} ,
} ,
2025-02-17 13:07:43 +01:00
collections : {
2025-04-08 16:01:10 +02:00
replies : new URL (
` /notes/ ${ status . id } /replies ` ,
config . http . base_url ,
2025-04-16 16:35:17 +02:00
) . href ,
2025-04-08 16:01:10 +02:00
quotes : new URL (
` /notes/ ${ status . id } /quotes ` ,
config . http . base_url ,
2025-04-16 16:35:17 +02:00
) . href ,
2025-05-02 12:48:47 +02:00
"pub.versia:share/Shares" : new URL (
` /notes/ ${ status . id } /shares ` ,
config . http . base_url ,
) . href ,
2025-02-17 13:07:43 +01:00
} ,
2025-04-08 16:01:10 +02:00
attachments : status.attachments.map (
( attachment ) = >
new Media ( attachment ) . toVersia ( ) . data as z . infer <
typeof NonTextContentFormatSchema
> ,
2024-04-17 06:09:21 +02:00
) ,
is_sensitive : status.sensitive ,
2025-04-16 16:35:17 +02:00
mentions : status.mentions.map (
( mention ) = >
User . getUri (
mention . id ,
mention . uri ? new URL ( mention . uri ) : null ,
) . href ,
2024-06-13 23:53:41 +02:00
) ,
2025-02-17 23:31:39 +01:00
quotes : status.quote
2025-04-08 16:01:10 +02:00
? status . quote . uri
2025-04-16 16:35:17 +02:00
? new URL ( status . quote . uri ) . href
2025-04-08 16:01:10 +02:00
: new URL ( ` /notes/ ${ status . quote . id } ` , config . http . base_url )
2025-04-16 16:35:17 +02:00
. href
2025-02-17 23:31:39 +01:00
: null ,
replies_to : status.reply
2025-04-08 16:01:10 +02:00
? status . reply . uri
2025-04-16 16:35:17 +02:00
? new URL ( status . reply . uri ) . href
2025-04-08 16:01:10 +02:00
: new URL ( ` /notes/ ${ status . reply . id } ` , config . http . base_url )
2025-04-16 16:35:17 +02:00
. href
2025-02-17 23:31:39 +01:00
: null ,
2024-04-17 06:09:21 +02:00
subject : status.spoilerText ,
2024-08-26 19:06:49 +02:00
// TODO: Refactor as part of groups
group : status.visibility === "public" ? "public" : "followers" ,
2024-04-17 06:09:21 +02:00
extensions : {
2024-08-26 19:27:40 +02:00
"pub.versia:custom_emojis" : {
2024-06-13 06:52:01 +02:00
emojis : status.emojis.map ( ( emoji ) = >
2024-08-19 15:16:01 +02:00
new Emoji ( emoji ) . toVersia ( ) ,
2024-06-13 06:52:01 +02:00
) ,
2024-04-17 06:09:21 +02:00
} ,
// TODO: Add polls and reactions
} ,
2025-04-08 16:01:10 +02:00
} ) ;
2024-04-17 06:09:21 +02:00
}
2025-05-02 12:48:47 +02:00
public toVersiaShare ( ) : VersiaEntities . Share {
if ( ! ( this . data . reblogId && this . data . reblog ) ) {
throw new Error ( "Cannot share a non-reblogged note" ) ;
}
return new VersiaEntities . Share ( {
type : "pub.versia:share/Share" ,
id : crypto.randomUUID ( ) ,
author : this.author.uri.href ,
uri : new URL ( ` /shares/ ${ this . id } ` , config . http . base_url ) . href ,
created_at : new Date ( ) . toISOString ( ) ,
shared : new Note ( this . data . reblog as NoteTypeWithRelations ) . getUri ( )
. href ,
} ) ;
}
public toVersiaUnshare ( ) : VersiaEntities . Delete {
return new VersiaEntities . Delete ( {
type : "Delete" ,
id : crypto.randomUUID ( ) ,
created_at : new Date ( ) . toISOString ( ) ,
author : User.getUri (
this . data . authorId ,
this . data . author . uri ? new URL ( this . data . author . uri ) : null ,
) . href ,
deleted_type : "pub.versia:share/Share" ,
deleted : new URL ( ` /shares/ ${ this . id } ` , config . http . base_url ) . href ,
} ) ;
}
2024-04-17 06:09:21 +02:00
/ * *
* Return all the ancestors of this post ,
2024-06-13 10:52:03 +02:00
* i . e . all the posts that this post is a reply to
* @param fetcher - The user fetching the ancestors
* @returns The ancestors of this post
2024-04-17 06:09:21 +02:00
* /
2024-11-01 21:20:12 +01:00
public async getAncestors ( fetcher : User | null ) : Promise < Note [ ] > {
2024-04-17 06:09:21 +02:00
const ancestors : Note [ ] = [ ] ;
let currentStatus : Note = this ;
2024-06-13 02:45:07 +02:00
while ( currentStatus . data . replyId ) {
2024-05-09 01:19:53 +02:00
const parent = await Note . fromId (
2024-06-13 02:45:07 +02:00
currentStatus . data . replyId ,
2024-05-09 01:19:53 +02:00
fetcher ? . id ,
) ;
2024-04-17 06:09:21 +02:00
if ( ! parent ) {
break ;
}
ancestors . push ( parent ) ;
currentStatus = parent ;
}
// Filter for posts that are viewable by the user
2024-11-19 17:26:14 +01:00
const viewableAncestors = await Promise . all (
ancestors . map ( async ( ancestor ) = > {
const isViewable = await ancestor . isViewableByUser ( fetcher ) ;
return isViewable ? ancestor : null ;
} ) ,
) . then ( ( filteredAncestors ) = >
filteredAncestors . filter ( ( n ) = > n !== null ) ,
2024-04-17 06:09:21 +02:00
) ;
2024-05-12 04:44:00 +02:00
// Reverse the order so that the oldest posts are first
return viewableAncestors . toReversed ( ) ;
2024-04-17 06:09:21 +02:00
}
/ * *
* Return all the descendants of this post ( recursive )
* Temporary implementation , will be replaced with a recursive SQL query when I get to it
2024-06-13 10:52:03 +02:00
* @param fetcher - The user fetching the descendants
* @param depth - The depth of the recursion ( internal )
* @returns The descendants of this post
2024-04-17 06:09:21 +02:00
* /
2024-11-01 21:20:12 +01:00
public async getDescendants (
fetcher : User | null ,
depth = 0 ,
) : Promise < Note [ ] > {
2024-04-17 06:09:21 +02:00
const descendants : Note [ ] = [ ] ;
2024-05-09 01:19:53 +02:00
for ( const child of await this . getReplyChildren ( fetcher ? . id ) ) {
2024-04-17 06:09:21 +02:00
descendants . push ( child ) ;
if ( depth < 20 ) {
const childDescendants = await child . getDescendants (
fetcher ,
depth + 1 ,
) ;
descendants . push ( . . . childDescendants ) ;
}
}
// Filter for posts that are viewable by the user
2024-11-19 17:26:14 +01:00
const viewableDescendants = await Promise . all (
descendants . map ( async ( descendant ) = > {
const isViewable = await descendant . isViewableByUser ( fetcher ) ;
return isViewable ? descendant : null ;
} ) ,
) . then ( ( filteredDescendants ) = >
filteredDescendants . filter ( ( n ) = > n !== null ) ,
2024-04-17 06:09:21 +02:00
) ;
2024-05-12 04:44:00 +02:00
2024-04-17 06:09:21 +02:00
return viewableDescendants ;
}
2025-05-25 16:11:56 +02:00
/ * *
* Get reactions for this note grouped by emoji name
* @param user - The user requesting reactions ( to determine 'me' field )
* @returns Array of reactions grouped by emoji name with counts and account IDs
* /
public getReactions (
user? : User ,
) : z . infer < typeof NoteReactionWithAccounts > [ ] {
// Group reactions by emoji name (either emojiText for Unicode or formatted shortcode for custom)
const groupedReactions = new Map <
string ,
{
count : number ;
me : boolean ;
2025-05-28 17:07:24 +02:00
instance : typeof Instance . $type | null ;
2025-05-25 16:11:56 +02:00
account_ids : string [ ] ;
}
> ( ) ;
for ( const reaction of this . data . reactions ) {
let emojiName : string ;
// Determine emoji name based on type
if ( reaction . emojiText ) {
emojiName = reaction . emojiText ;
2025-05-28 17:07:24 +02:00
} else if ( reaction . emoji ? . instance === null ) {
2025-05-25 16:11:56 +02:00
emojiName = ` : ${ reaction . emoji . shortcode } : ` ;
2025-05-28 17:07:24 +02:00
} else if ( reaction . emoji ? . instance ) {
emojiName = ` : ${ reaction . emoji . shortcode } @ ${ reaction . emoji . instance . baseUrl } : ` ;
2025-05-25 16:11:56 +02:00
} else {
continue ; // Skip invalid reactions
}
// Initialize group if it doesn't exist
if ( ! groupedReactions . has ( emojiName ) ) {
groupedReactions . set ( emojiName , {
count : 0 ,
me : false ,
account_ids : [ ] ,
2025-05-28 17:07:24 +02:00
instance : reaction.emoji?.instance ? ? null ,
2025-05-25 16:11:56 +02:00
} ) ;
}
const group = groupedReactions . get ( emojiName ) ;
if ( ! group ) {
continue ;
}
group . count += 1 ;
group . account_ids . push ( reaction . authorId ) ;
// Check if current user reacted with this emoji
if ( user && reaction . authorId === user . id ) {
group . me = true ;
}
}
// Convert map to array format
return Array . from ( groupedReactions . entries ( ) ) . map ( ( [ name , data ] ) = > ( {
name ,
count : data.count ,
me : data.me ,
account_ids : data.account_ids ,
2025-05-28 17:07:24 +02:00
remote : data.instance !== null ,
2025-05-25 16:11:56 +02:00
} ) ) ;
}
2024-04-17 06:09:21 +02:00
}