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" ;
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-16 18:09:21 -10:00
import {
and ,
desc ,
eq ,
2025-04-10 19:15:31 +02:00
type InferInsertModel ,
type InferSelectModel ,
2024-04-16 18:09:21 -10:00
inArray ,
2024-04-24 17:40:27 -10:00
isNotNull ,
2025-04-10 19:15:31 +02:00
type SQL ,
2024-05-06 07:16:33 +00:00
sql ,
2024-04-16 18:09:21 -10: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 { 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" ) ,
} ,
} ,
2026-02-25 02:34:27 +01:00
reply : {
with : {
author : {
with : {
instance : true ,
} ,
} ,
} ,
} ,
quote : {
with : {
author : {
with : {
instance : true ,
} ,
} ,
} ,
} ,
2025-06-15 23:43:27 +02:00
} ,
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 ) ,
2026-02-25 02:34:27 +01:00
mentions : post.mentions.map ( ( mention ) = > mention . user ) ,
2025-06-15 23:43:27 +02:00
attachments : post.attachments.map ( ( attachment ) = > attachment . media ) ,
emojis : ( post . emojis ? ? [ ] ) . map ( ( emoji ) = > emoji . emoji ) ,
reblog : post.reblog && {
. . . post . reblog ,
author : transformOutputToUserWithRelations ( post . reblog . author ) ,
2026-02-25 02:34:27 +01:00
mentions : post.reblog.mentions.map ( ( mention ) = > mention . user ) ,
2025-06-15 23:43:27 +02:00
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-16 18:09:21 -10: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 ) [ ] ;
2026-02-25 02:34:27 +01:00
reply :
| ( NoteType & {
author : InferSelectModel < typeof Users > & {
instance : typeof Instance . $type | null ;
} ;
} )
| null ;
quote :
| ( NoteType & {
author : InferSelectModel < typeof Users > & {
instance : typeof Instance . $type | null ;
} ;
} )
| 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-16 18:09:21 -10: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-12 14:45:07 -10: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-12 14:45:07 -10:00
if ( ! reloaded ) {
throw new Error ( "Failed to reload status" ) ;
}
this . data = reloaded . data ;
}
2024-06-12 22:52:03 -10: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-12 14:45:07 -10: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-12 14:45:07 -10:00
return note ;
}
2024-04-16 18:09:21 -10:00
2024-06-12 22:52:03 -10: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-08 13:19:53 -10:00
id : string | null ,
2024-06-12 14:45:07 -10:00
userRequestingNoteId? : string ,
2024-05-08 13:19:53 -10:00
) : Promise < Note | null > {
2024-06-12 16:26:43 -10:00
if ( ! id ) {
return null ;
}
2024-04-16 18:09:21 -10:00
2024-06-12 14:45:07 -10:00
return await Note . fromSql (
eq ( Notes . id , id ) ,
undefined ,
userRequestingNoteId ,
) ;
2024-04-16 18:09:21 -10:00
}
2024-06-12 22:52:03 -10: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-12 14:45:07 -10:00
ids : string [ ] ,
userRequestingNoteId? : string ,
) : Promise < Note [ ] > {
2024-05-08 13:19:53 -10:00
return await Note . manyFromSql (
inArray ( Notes . id , ids ) ,
undefined ,
undefined ,
undefined ,
2024-06-12 14:45:07 -10:00
userRequestingNoteId ,
2024-05-08 13:19:53 -10:00
) ;
2024-04-16 18:09:21 -10:00
}
2024-06-12 22:52:03 -10: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-16 18:09:21 -10:00
sql : SQL < unknown > | undefined ,
2024-04-16 20:36:01 -10:00
orderBy : SQL < unknown > | undefined = desc ( Notes . id ) ,
2024-05-08 13:19:53 -10:00
userId? : string ,
2024-06-12 22:52:03 -10:00
) : Promise < Note | null > {
2024-05-08 13:19:53 -10:00
const found = await findManyNotes (
{
where : sql ,
orderBy ,
limit : 1 ,
} ,
userId ,
) ;
2024-04-16 18:09:21 -10:00
2024-06-12 16:26:43 -10:00
if ( ! found [ 0 ] ) {
return null ;
}
2024-05-08 11:51:47 -10:00
return new Note ( found [ 0 ] ) ;
2024-04-16 18:09:21 -10:00
}
2024-06-12 22:52:03 -10: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-16 18:09:21 -10:00
sql : SQL < unknown > | undefined ,
2024-04-16 20:36:01 -10:00
orderBy : SQL < unknown > | undefined = desc ( Notes . id ) ,
2024-04-16 18:09:21 -10:00
limit? : number ,
offset? : number ,
2024-05-08 13:19:53 -10:00
userId? : string ,
2024-06-12 22:52:03 -10:00
) : Promise < Note [ ] > {
2024-05-08 13:19:53 -10:00
const found = await findManyNotes (
{
where : sql ,
orderBy ,
limit ,
offset ,
} ,
userId ,
) ;
2024-04-16 18:09:21 -10:00
return found . map ( ( s ) = > new Note ( s ) ) ;
}
2024-11-02 00:43:33 +01:00
public get id ( ) : string {
2024-06-12 14:45:07 -10:00
return this . data . id ;
2024-04-24 17:40:27 -10:00
}
2026-02-25 02:34:27 +01:00
public get reference ( ) : VersiaEntities . Reference {
if ( this . remote ) {
const instanceUrl = new URL (
this . author . data . instance ? . baseUrl || "" ,
) ;
return new VersiaEntities . Reference ( this . id , instanceUrl . hostname ) ;
}
return new VersiaEntities . Reference ( this . id ) ;
}
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-12 22:52:03 -10: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-16 18:09:21 -10:00
// Mentioned users
const mentionedUsers =
2024-06-12 14:45:07 -10:00
this . data . mentions . length > 0
2024-04-24 17:40:27 -10:00
? await User . manyFromSql (
and (
isNotNull ( Users . instanceId ) ,
inArray (
Users . id ,
2024-06-12 14:45:07 -10:00
this . data . mentions . map ( ( mention ) = > mention . id ) ,
2024-04-16 18:09:21 -10:00
) ,
2024-04-24 17:40:27 -10:00
) ,
)
2024-04-16 18:09:21 -10:00
: [ ] ;
2024-04-24 17:40:27 -10: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-24 17:40:27 -10:00
and (
2024-07-26 19:21:03 +02:00
eq ( relationship . subjectId , this . data . authorId ) ,
2024-04-24 17:40:27 -10:00
eq ( relationship . following , true ) ,
) ,
} ,
2024-04-16 18:09:21 -10:00
} ,
} ,
2024-04-24 17:40:27 -10:00
) ;
2024-04-16 18:09:21 -10:00
2024-12-09 13:50:46 +01:00
const fusedUsers = mergeAndDeduplicate (
mentionedUsers ,
usersThatCanSeePost ,
2024-04-16 18:09:21 -10:00
) ;
2024-12-09 13:50:46 +01:00
return fusedUsers ;
2024-04-16 18:09:21 -10:00
}
2024-11-02 00:43:33 +01:00
public get author ( ) : User {
2024-06-12 14:45:07 -10:00
return new User ( this . data . author ) ;
2024-04-16 18:09:21 -10:00
}
2024-06-12 22:52:03 -10: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 07:16:33 +00: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
2026-02-25 02:34:27 +01:00
* @param remoteId The remote ID of the reblog , if it is from a remote user
2025-07-04 06:29:43 +02:00
* @returns The reblog object created or the existing reblog
* /
public async reblog (
reblogger : User ,
visibility : z.infer < typeof StatusSchema.shape.visibility > ,
2026-02-25 02:34:27 +01:00
remoteId? : string ,
2025-07-04 06:29:43 +02:00
) : 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 ,
2025-12-11 04:03:57 +01:00
updatedAt : new Date ( ) ,
2025-08-21 01:21:32 +02:00
clientId : null ,
2026-02-25 02:34:27 +01:00
remoteId ,
2025-07-04 06:29:43 +02:00
} ) ;
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
2026-02-25 02:34:27 +01:00
* @param remoteId The id of the like , if it is remote
2025-07-04 06:29:43 +02:00
* @returns The like object created or the existing like
* /
2026-02-25 02:34:27 +01:00
public async like ( liker : User , remoteId? : string ) : Promise < Like > {
2025-07-04 06:29:43 +02:00
// 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 ,
2026-02-25 02:34:27 +01:00
remoteId ,
2025-07-04 06:29:43 +02:00
} ) ;
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-12 22:52:03 -10: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-08 13:19:53 -10:00
return await Note . manyFromSql (
2024-06-12 14:45:07 -10:00
eq ( Notes . replyId , this . data . id ) ,
2024-05-08 13:19:53 -10:00
undefined ,
undefined ,
undefined ,
userId ,
) ;
2024-04-16 20:36:01 -10:00
}
2025-04-08 18:13:30 +02:00
public get remote ( ) : boolean {
return this . author . remote ;
2024-06-05 21:04:52 -10:00
}
2025-04-08 18:13:30 +02:00
public get local ( ) : boolean {
return this . author . local ;
2024-06-12 18:16:59 -10: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-12 22:52:03 -10: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-16 18:09:21 -10:00
// Connect emojis
await db
2024-04-16 20:36:01 -10:00
. delete ( EmojiToNote )
2024-06-12 14:45:07 -10: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-12 18:16:59 -10:00
}
2024-04-16 18:09:21 -10:00
2024-06-12 22:52:03 -10: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-16 18:09:21 -10:00
// Connect mentions
await db
2024-04-16 20:36:01 -10:00
. delete ( NoteToMentions )
2024-06-12 14:45:07 -10: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-12 18:16:59 -10:00
}
2024-04-16 18:09:21 -10:00
2024-06-12 22:52:03 -10: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-12 18:16:59 -10: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-16 18:09:21 -10:00
}
2024-06-12 22:52:03 -10:00
/ * *
2026-02-25 02:34:27 +01:00
* Resolve a note from a reference
* @param reference - The URI of the note to resolve
2024-06-12 22:52:03 -10:00
* @returns The resolved note
* /
2026-02-25 02:34:27 +01:00
public static async resolve (
reference : VersiaEntities.Reference ,
2026-03-31 04:13:16 +02:00
defaultInstance? : Instance ,
2026-02-25 02:34:27 +01:00
) : Promise < Note | null > {
2024-06-05 21:04:52 -10:00
// Check if note not already in database
2026-02-25 02:34:27 +01:00
if (
2026-03-31 04:13:16 +02:00
! ( reference . domain || defaultInstance ) ||
2026-02-25 02:34:27 +01:00
reference . domain === config . http . base_url . hostname
) {
return await Note . fromId ( reference . id ) ;
}
2026-03-31 04:13:16 +02:00
const instance = reference . domain
? await Instance . resolve ( reference . domain )
: ( defaultInstance as Instance ) ;
2026-02-25 02:34:27 +01:00
const foundNote = await Note . fromSql (
and (
eq ( Notes . remoteId , reference . id ) ,
eq (
Notes . authorId ,
sql ` (
SELECT "Users" . id FROM "Users"
2026-03-31 04:13:16 +02:00
WHERE "Users" . "instanceId" = $ { instance . id }
2026-02-25 02:34:27 +01:00
LIMIT 1
) ` ,
) ,
) ,
) ;
2024-06-05 21:04:52 -10:00
2024-06-12 16:26:43 -10:00
if ( foundNote ) {
return foundNote ;
}
2024-06-05 21:04:52 -10:00
2026-03-31 04:13:16 +02:00
return Note . fromVersia (
reference . domain
? reference
: new VersiaEntities . Reference (
reference . id ,
instance . data . baseUrl ,
) ,
) ;
2024-06-12 18:16:59 -10: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 .
2026-02-25 02:34:27 +01:00
* @param versiaNote - Reference or Versia Note representation
2025-04-08 18:13:30 +02:00
* /
2026-03-31 04:13:16 +02:00
public static async fromVersia (
versiaNote : VersiaEntities.Note ,
instance : Instance ,
) : Promise < Note > ;
public static async fromVersia (
reference : VersiaEntities.Reference ,
) : Promise < Note > ;
2025-04-08 18:13:30 +02:00
public static async fromVersia (
2026-02-25 02:34:27 +01:00
versiaNote : VersiaEntities.Note | VersiaEntities . Reference ,
2026-03-31 04:13:16 +02:00
instance? : Instance ,
2025-04-08 18:13:30 +02:00
) : Promise < Note > {
2026-02-25 02:34:27 +01:00
if ( versiaNote instanceof VersiaEntities . Reference ) {
2026-03-31 04:13:16 +02:00
if ( ! versiaNote . domain ) {
throw new Error (
"Cannot fetch Versia note from reference without domain" ,
) ;
}
2025-04-08 18:13:30 +02:00
// No bridge support for notes yet
2026-02-25 02:34:27 +01:00
const note = await Instance . federationRequester . fetchEntity (
versiaNote ,
VersiaEntities . Note ,
) ;
2024-06-05 21:04:52 -10:00
2026-03-31 04:13:16 +02:00
const instance = await Instance . resolve ( versiaNote . domain ) ;
return Note . fromVersia ( note , instance ) ;
}
if ( ! instance ) {
throw new Error ( "Instance must be provided when fetching note" ) ;
2025-04-08 18:13:30 +02:00
}
2026-02-25 02:34:27 +01:00
const { created_at , extensions , group , id , is_sensitive , subject } =
versiaNote . data ;
2026-03-31 04:13:16 +02:00
const author = await User . resolve ( versiaNote . author , instance ) ;
2025-04-08 18:13:30 +02:00
if ( ! author ) {
throw new Error ( "Entity author could not be resolved" ) ;
}
2026-02-25 02:34:27 +01:00
const existingNote = await Note . fromSql (
and ( eq ( Notes . remoteId , id ) , eq ( Notes . authorId , author . id ) ) ,
) ;
2025-04-08 18:13:30 +02:00
const note =
existingNote ? ?
( await Note . insert ( {
id : randomUUIDv7 ( ) ,
authorId : author.id ,
visibility : "public" ,
2026-02-25 02:34:27 +01:00
remoteId : id ,
2025-12-11 04:03:57 +01:00
createdAt : new Date ( created_at ) ,
2025-04-08 18:13:30 +02:00
} ) ) ;
const attachments = await Promise . all (
versiaNote . attachments . map ( ( a ) = > Media . fromVersia ( a ) ) ,
) ;
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
) ;
const mentions = (
await Promise . all (
2026-03-31 04:13:16 +02:00
versiaNote . mentions . map ( ( m ) = > User . resolve ( m , instance ) ) ? ? [ ] ,
2025-04-08 18:13:30 +02:00
)
) . filter ( ( m ) = > m !== null ) ;
// TODO: Implement groups
2025-04-16 16:35:17 +02:00
const visibility =
! group || URL . canParse ( group )
? "direct"
: ( group as "public" | "followers" | "unlisted" ) ;
2025-04-08 18:13:30 +02:00
2026-02-25 02:34:27 +01:00
const reply = versiaNote . repliesTo
2026-03-31 04:13:16 +02:00
? await Note . resolve ( versiaNote . repliesTo , instance )
2026-02-25 02:34:27 +01:00
: null ;
const quote = versiaNote . quotes
2026-03-31 04:13:16 +02:00
? await Note . resolve ( versiaNote . quotes , instance )
2025-04-16 16:35:17 +02:00
: 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 )
2025-04-08 18:13:30 +02:00
: undefined ,
contentSource : versiaNote.content
? versiaNote . content . data [ "text/plain" ] ? . content ||
versiaNote . content . data [ "text/markdown" ] ? . content
: undefined ,
contentType : "text/html" ,
visibility : visibility === "followers" ? "private" : visibility ,
sensitive : is_sensitive ? ? false ,
spoilerText : spoiler ,
replyId : reply?.id ,
quotingId : quote?.id ,
} ) ;
// 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 ) ;
await note . reload ( author . id ) ;
// Send notifications for mentioned local users
for ( const mentioned of mentions ) {
if ( mentioned . local ) {
await mentioned . notify ( "mention" , author , note ) ;
2024-06-05 21:04:52 -10:00
}
}
2025-04-08 18:13:30 +02:00
return note ;
2024-06-05 21:04:52 -10: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-16 18:09:21 -10: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-12 14:45:07 -10:00
await db . update ( Notes ) . set ( newStatus ) . where ( eq ( Notes . id , this . data . id ) ) ;
2024-04-16 18:09:21 -10:00
2024-06-12 14:45:07 -10:00
const updated = await Note . fromId ( this . data . id ) ;
if ( ! updated ) {
throw new Error ( "Failed to update status" ) ;
}
return updated . data ;
2024-04-16 18:09:21 -10: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-12 16:26:43 -10: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-12 14:45:07 -10:00
if ( this . data . visibility === "private" ) {
2024-04-16 18:09:21 -10:00
return user
2024-06-12 22:52:03 -10:00
? ! ! ( await db . query . Relationships . findFirst ( {
2025-05-26 19:00:24 +02:00
where : ( relationship ) : SQL | undefined = >
2024-04-16 18:09:21 -10:00
and (
eq ( relationship . ownerId , user ? . id ) ,
2024-04-16 20:36:01 -10:00
eq ( relationship . subjectId , Notes . authorId ) ,
2024-04-16 18:09:21 -10:00
eq ( relationship . following , true ) ,
) ,
2024-06-12 22:52:03 -10:00
} ) )
2024-04-16 18:09:21 -10:00
: false ;
}
return (
2024-06-12 22:52:03 -10:00
! ! user &&
! ! this . data . mentions . find ( ( mention ) = > mention . id === user . id )
2024-04-16 18:09:21 -10:00
) ;
}
2024-06-12 22:52:03 -10: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-12 14:45:07 -10:00
const data = this . data ;
2024-04-16 18:09:21 -10: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-16 18:09:21 -10: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-16 18:09:21 -10: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-16 18:09:21 -10:00
return {
id : data.id ,
2024-04-16 20:36:01 -10:00
in_reply_to_id : data.replyId || null ,
in_reply_to_account_id : data.reply?.authorId || null ,
2024-06-12 16:26:43 -10:00
account : this.author.toApi ( userFetching ? . id === data . authorId ) ,
2025-12-11 04:03:57 +01:00
created_at : 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-16 18:09:21 -10:00
card : null ,
content : replacedContent ,
2025-05-26 15:13:56 +02:00
emojis : emojis.map ( ( emoji ) = > new Emoji ( emoji ) . toApi ( ) ) ,
2024-05-08 13:19:53 -10:00
favourited : data.liked ,
2024-05-08 10:57:42 -10: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-16 18:09:21 -10:00
) ,
2024-04-24 17:40:27 -10:00
mentions : data.mentions.map ( ( mention ) = > ( {
id : mention.id ,
acct : User.getAcct (
mention . instanceId === null ,
mention . username ,
mention . instance ? . baseUrl ,
) ,
2026-02-25 02:34:27 +01:00
url : new URL (
` /@ ${ mention . username } ${
mention . instance ? ` @ ${ mention . instance . baseUrl } ` : ""
} ` ,
config . http . base_url ,
2025-02-01 16:32:18 +01:00
) . toString ( ) ,
2024-04-24 17:40:27 -10:00
username : mention.username ,
} ) ) ,
2024-04-16 18:09:21 -10:00
language : null ,
2024-05-08 13:19:53 -10:00
muted : data.muted ,
pinned : data.pinned ,
2024-04-16 18:09:21 -10:00
// TODO: Add polls
poll : null ,
2024-04-27 20:15:08 -10:00
reblog : data.reblog
2024-11-04 14:58:17 +01:00
? await new Note ( data . reblog as NoteTypeWithRelations ) . toApi (
2024-06-12 14:45:07 -10:00
userFetching ,
)
2024-04-27 20:15:08 -10:00
: null ,
2024-05-08 13:19:53 -10:00
reblogged : data.reblogged ,
2024-04-16 18:09:21 -10:00
reblogs_count : data.reblogCount ,
replies_count : data.replyCount ,
sensitive : data.sensitive ,
spoiler_text : data.spoilerText ,
tags : [ ] ,
2026-02-25 02:34:27 +01:00
uri : this.getUri ( ) . toString ( ) ,
2025-02-12 23:25:22 +01:00
visibility : data.visibility ,
2026-02-25 02:34:27 +01:00
url : this.getMastoUri ( ) . toString ( ) ,
2024-04-16 18:09:21 -10:00
bookmarked : false ,
2024-04-27 20:15:08 -10:00
quote : data.quotingId
2024-09-14 17:32:32 +02:00
? ( ( await Note . fromId ( data . quotingId , userFetching ? . id ) . then (
2024-06-12 16:26:43 -10:00
( n ) = > n ? . toApi ( userFetching ) ,
2024-09-14 17:32:32 +02:00
) ) ? ? null )
2024-04-27 20:15:08 -10:00
: null ,
2025-12-11 04:03:57 +01:00
edited_at : data.updatedAt.toISOString ( ) ,
2025-05-26 15:13:56 +02:00
reactions ,
2025-03-22 18:04:47 +01:00
text : data.contentSource ,
2024-04-16 18:09:21 -10:00
} ;
}
2025-02-01 16:32:18 +01:00
public getUri ( ) : URL {
2026-02-25 02:34:27 +01:00
const domain = this . author . data . instance ? . baseUrl
? new URL ( ` https:// ${ this . author . data . instance . baseUrl } ` )
: config . http . base_url ;
return new URL (
` /.versia/v0.6/entities/Note/ ${ this . id } ` ,
` https:// ${ domain } ` ,
) ;
2024-04-24 17:40:27 -10:00
}
2024-06-12 22:52:03 -10: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-09 15:17:03 -10:00
return new URL (
2024-06-12 14:45:07 -10:00
` /@ ${ this . author . data . username } / ${ this . id } ` ,
2024-06-09 15:17:03 -10:00
config . http . base_url ,
2025-02-01 16:32:18 +01:00
) ;
2024-04-16 18:09:21 -10:00
}
2025-04-08 16:01:10 +02:00
public deleteToVersia ( ) : VersiaEntities . Delete {
return new VersiaEntities . Delete ( {
2024-08-26 19:06:49 +02:00
type : "Delete" ,
2026-02-25 02:34:27 +01:00
author : this.author.id ,
2024-08-26 19:06:49 +02:00
deleted_type : "Note" ,
2026-02-25 02:34:27 +01:00
deleted : this.id ,
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-12 22:52:03 -10: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-12 22:52:03 -10:00
* /
2025-04-08 16:01:10 +02:00
public toVersia ( ) : VersiaEntities . Note {
2024-06-12 14:45:07 -10:00
const status = this . data ;
2026-02-25 02:34:27 +01:00
let quoteReference = status . quote ? . id ? ? null ;
if ( quoteReference && status . quote ? . author . instance ) {
quoteReference = ` ${ status . quote . author . instance . baseUrl } : ${ status . quote . remoteId } ` ;
}
let replyReference = status . reply ? . id ? ? null ;
if ( replyReference && status . reply ? . author . instance ) {
replyReference = ` ${ status . reply . author . instance . baseUrl } : ${ status . reply . remoteId } ` ;
}
2025-04-08 16:01:10 +02:00
return new VersiaEntities . Note ( {
2024-04-16 18:09:21 -10:00
type : "Note" ,
2025-12-11 04:03:57 +01:00
created_at : status.createdAt.toISOString ( ) ,
2024-04-16 18:09:21 -10:00
id : status.id ,
2026-02-25 02:34:27 +01:00
author : this.author.id ,
2024-04-16 18:09:21 -10:00
content : {
"text/html" : {
content : status.content ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-16 18:09:21 -10:00
} ,
"text/plain" : {
content : htmlToText ( status . content ) ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-16 18:09:21 -10:00
} ,
} ,
2026-02-25 02:34:27 +01:00
previews : [ ] ,
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-16 18:09:21 -10:00
) ,
is_sensitive : status.sensitive ,
2026-02-25 02:34:27 +01:00
mentions : status.mentions.map ( ( mention ) = >
mention . instance
? ` ${ mention . instance . baseUrl } : ${ mention . id } `
: mention . id ,
2024-06-13 11:53:41 -10:00
) ,
2026-02-25 02:34:27 +01:00
quotes : quoteReference ,
replies_to : replyReference ,
2024-04-16 18:09:21 -10: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-16 18:09:21 -10:00
extensions : {
2024-08-26 19:27:40 +02:00
"pub.versia:custom_emojis" : {
2024-06-12 18:52:01 -10:00
emojis : status.emojis.map ( ( emoji ) = >
2024-08-19 15:16:01 +02:00
new Emoji ( emoji ) . toVersia ( ) ,
2024-06-12 18:52:01 -10:00
) ,
2024-04-16 18:09:21 -10:00
} ,
// TODO: Add polls and reactions
} ,
2025-04-08 16:01:10 +02:00
} ) ;
2024-04-16 18:09:21 -10: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" ,
2026-02-25 02:34:27 +01:00
author : this.author.id ,
id : this.id ,
2025-05-02 12:48:47 +02:00
created_at : new Date ( ) . toISOString ( ) ,
2026-02-25 02:34:27 +01:00
shared : this.data.reblog.author.instance
? ` ${ this . data . reblog . author . instance . baseUrl } : ${ this . data . reblog . id } `
: this . data . reblog . id ,
2025-05-02 12:48:47 +02:00
} ) ;
}
public toVersiaUnshare ( ) : VersiaEntities . Delete {
return new VersiaEntities . Delete ( {
type : "Delete" ,
created_at : new Date ( ) . toISOString ( ) ,
2026-02-25 02:34:27 +01:00
author : this.author.id ,
2025-05-02 12:48:47 +02:00
deleted_type : "pub.versia:share/Share" ,
2026-02-25 02:34:27 +01:00
deleted : this.id ,
2025-05-02 12:48:47 +02:00
} ) ;
}
2024-04-16 18:09:21 -10:00
/ * *
* Return all the ancestors of this post ,
2024-06-12 22:52:03 -10: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-16 18:09:21 -10:00
* /
2024-11-01 21:20:12 +01:00
public async getAncestors ( fetcher : User | null ) : Promise < Note [ ] > {
2024-04-16 18:09:21 -10:00
const ancestors : Note [ ] = [ ] ;
let currentStatus : Note = this ;
2024-06-12 14:45:07 -10:00
while ( currentStatus . data . replyId ) {
2024-05-08 13:19:53 -10:00
const parent = await Note . fromId (
2024-06-12 14:45:07 -10:00
currentStatus . data . replyId ,
2024-05-08 13:19:53 -10:00
fetcher ? . id ,
) ;
2024-04-16 18:09:21 -10: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-16 18:09:21 -10:00
) ;
2024-05-11 16:44:00 -10:00
// Reverse the order so that the oldest posts are first
return viewableAncestors . toReversed ( ) ;
2024-04-16 18:09:21 -10: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-12 22:52:03 -10:00
* @param fetcher - The user fetching the descendants
* @param depth - The depth of the recursion ( internal )
* @returns The descendants of this post
2024-04-16 18:09:21 -10:00
* /
2024-11-01 21:20:12 +01:00
public async getDescendants (
fetcher : User | null ,
depth = 0 ,
) : Promise < Note [ ] > {
2024-04-16 18:09:21 -10:00
const descendants : Note [ ] = [ ] ;
2024-05-08 13:19:53 -10:00
for ( const child of await this . getReplyChildren ( fetcher ? . id ) ) {
2024-04-16 18:09:21 -10: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-16 18:09:21 -10:00
) ;
2024-05-11 16:44:00 -10:00
2024-04-16 18:09:21 -10: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-16 18:09:21 -10:00
}