Full user packing in the backend and Magnetar-filled preview cards in the frontend
ci/woodpecker/push/ociImagePush Pipeline was successful Details

This commit is contained in:
Natty 2023-11-09 21:35:55 +01:00
parent d1ca62a807
commit 81d0c678d8
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
22 changed files with 655 additions and 186 deletions

View File

@ -114,6 +114,26 @@ impl CalckeyModel {
.await?)
}
pub async fn get_user_profile_by_id(
&self,
id: &str,
) -> Result<Option<user_profile::Model>, CalckeyDbError> {
Ok(user_profile::Entity::find()
.filter(user_profile::Column::UserId.eq(id))
.one(&self.0)
.await?)
}
pub async fn get_user_security_keys_by_id(
&self,
id: &str,
) -> Result<Vec<user_security_key::Model>, CalckeyDbError> {
Ok(user_security_key::Entity::find()
.filter(user_security_key::Column::UserId.eq(id))
.all(&self.0)
.await?)
}
pub async fn get_many_users_by_id(
&self,
id: &[String],
@ -160,6 +180,21 @@ impl CalckeyModel {
.await?)
}
pub async fn get_follow_request_status(
&self,
from: &str,
to: &str,
) -> Result<Option<follow_request::Model>, CalckeyDbError> {
Ok(follow_request::Entity::find()
.filter(
follow_request::Column::FollowerId
.eq(from)
.and(follow_request::Column::FolloweeId.eq(to)),
)
.one(&self.0)
.await?)
}
pub async fn get_block_status(
&self,
from: &str,

View File

@ -1,12 +1,14 @@
use lazy_static::lazy_static;
use sea_orm::sea_query::{Alias, Asterisk, Expr, IntoIden, Query, SelectExpr, SimpleExpr};
use sea_orm::sea_query::{
Alias, Asterisk, Expr, IntoCondition, IntoIden, Query, SelectExpr, SimpleExpr,
};
use sea_orm::{
ColumnTrait, DbErr, EntityName, EntityTrait, FromQueryResult, Iden, Iterable, JoinType,
QueryFilter, QueryResult, QuerySelect, QueryTrait, RelationTrait, Select,
QueryFilter, QueryOrder, QueryResult, QuerySelect, QueryTrait, RelationTrait, Select,
};
use serde::{Deserialize, Serialize};
use ck::{drive_file, note, note_reaction, user};
use ck::{drive_file, note, note_reaction, user, user_note_pining};
use magnetar_sdk::types::RangeFilter;
use crate::{AliasSourceExt, CalckeyDbError, CalckeyModel};
@ -53,6 +55,7 @@ pub struct NoteData {
pub renote: Option<Box<NoteData>>,
}
const PIN: &str = "pin.";
const INTERACTION_REACTION: &str = "interaction.reaction.";
const INTERACTION_RENOTE: &str = "interaction.renote.";
const USER: &str = "user.";
@ -154,6 +157,7 @@ pub struct NoteResolveOptions {
pub with_reply_target: bool,
pub with_renote_target: bool,
pub with_interactions_from: Option<String>, // User ID
pub only_pins_from: Option<String>, // User ID
}
trait SelectColumnsExt {
@ -262,6 +266,7 @@ impl SelectColumnsExt for Select<note::Entity> {
}
lazy_static! {
static ref ALIAS_PIN: Alias = Alias::new(PIN);
static ref ALIAS_INTERACTION_RENOTE: Alias = Alias::new(INTERACTION_RENOTE);
static ref ALIAS_INTERACTION_REACTION: Alias = Alias::new(INTERACTION_REACTION);
static ref ALIAS_USER: Alias = Alias::new(USER);
@ -281,6 +286,24 @@ lazy_static! {
static ref ALIAS_RENOTE_USER_BANNER: Alias = Alias::new(RENOTE_USER_BANNER);
}
fn range_into_expr(filter: &RangeFilter) -> SimpleExpr {
match filter {
RangeFilter::TimeStart(start) => note::Column::CreatedAt.gte(*start),
RangeFilter::TimeRange(range) => {
note::Column::CreatedAt.between(*range.start(), *range.end())
}
RangeFilter::TimeEnd(end) => note::Column::CreatedAt.lt(*end),
}
}
fn ids_into_expr(ids: &Vec<String>) -> SimpleExpr {
if ids.len() == 1 {
note::Column::Id.eq(&ids[0])
} else {
note::Column::Id.is_in(ids)
}
}
impl NoteResolver {
pub fn new(db: CalckeyModel) -> Self {
NoteResolver { db }
@ -292,21 +315,8 @@ impl NoteResolver {
) -> Result<Option<NoteData>, CalckeyDbError> {
let select = self.resolve(options);
let visibility_filter = options.visibility_filter.with_note_and_user_tables(None);
let time_filter = options.time_range.as_ref().map(|f| match f {
RangeFilter::TimeStart(start) => note::Column::CreatedAt.gte(*start),
RangeFilter::TimeRange(range) => {
note::Column::CreatedAt.between(*range.start(), *range.end())
}
RangeFilter::TimeEnd(end) => note::Column::CreatedAt.lt(*end),
});
let id_filter = options.ids.as_ref().map(|ids| {
if ids.len() == 1 {
note::Column::Id.eq(&ids[0])
} else {
note::Column::Id.is_in(ids)
}
});
let time_filter = options.time_range.as_ref().map(range_into_expr);
let id_filter = options.ids.as_ref().map(ids_into_expr);
let notes = select
.filter(visibility_filter)
@ -319,9 +329,49 @@ impl NoteResolver {
Ok(notes)
}
pub async fn get_many(
&self,
options: &NoteResolveOptions,
) -> Result<Vec<NoteData>, CalckeyDbError> {
let select = self.resolve(options);
let visibility_filter = options.visibility_filter.with_note_and_user_tables(None);
let time_filter = options.time_range.as_ref().map(range_into_expr);
let id_filter = options.ids.as_ref().map(ids_into_expr);
let notes = select
.filter(visibility_filter)
.apply_if(id_filter, Select::<note::Entity>::filter)
.apply_if(time_filter, Select::<note::Entity>::filter)
.apply_if(options.only_pins_from.as_deref(), |s, _| {
s.order_by_desc(Expr::col((
ALIAS_PIN.clone(),
user_note_pining::Column::CreatedAt,
)))
})
.into_model::<NoteData>()
.all(self.db.inner())
.await?;
Ok(notes)
}
pub fn resolve(&self, options: &NoteResolveOptions) -> Select<note::Entity> {
let mut select = note::Entity::find().add_aliased_columns(Some(USER), user::Entity);
if let Some(pins_user) = options.only_pins_from.clone() {
select = select.join_as(
JoinType::InnerJoin,
note::Relation::UserNotePining
.def()
.on_condition(move |left, _right| {
Expr::col((left, note::Column::UserId))
.eq(&pins_user)
.into_condition()
}),
ALIAS_PIN.clone(),
)
}
if let Some(user_id) = &options.with_interactions_from {
select = select
.add_sub_select_reaction(None, INTERACTION_REACTION, user_id)

View File

@ -1,7 +1,6 @@
<template>
<MkA
v-if="url.startsWith('/')"
v-user-preview="canonical"
v-user-preview="{ username, host }"
class="mention"
:class="{ isMe }"
:to="url"
@ -17,28 +16,12 @@
>
</span>
</MkA>
<a
v-else
class="mention"
:href="url"
target="_blank"
rel="noopener"
:style="{ background: bgCss }"
@click.stop
>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host">@{{ toUnicode(host) }}</span>
</span>
</a>
</template>
<script lang="ts" setup>
import { toUnicode } from "punycode";
import {} from "vue";
import { host as localHost } from "@/config";
import { $i } from "@/account";
const props = defineProps<{
username: string;
host: string;

View File

@ -19,25 +19,23 @@
}
"
>
<div v-if="user != null" class="info">
<div v-if="user" class="info">
<div
class="banner"
:style="
user.bannerUrl
? `background-image: url(${user.bannerUrl})`
user.banner_url
? `background-image: url(${user.banner_url})`
: ''
"
>
<span
v-if="$i && $i.id != user.id && user.isFollowed"
v-if="$i && $i.id != user.id && user.follows_you"
class="followed"
>{{ i18n.ts.followsYou }}</span
>
<span
v-if="
$i &&
$i.id != user.id &&
user.hasPendingFollowRequestToYou
$i && $i.id != user.id && user.they_request_follow
"
class="followed"
>
@ -100,15 +98,15 @@
<div class="status">
<div>
<p>{{ i18n.ts.notes }}</p>
<span>{{ user.notesCount }}</span>
<span>{{ user.note_count }}</span>
</div>
<div>
<p>{{ i18n.ts.following }}</p>
<span>{{ user.followingCount }}</span>
<span>{{ user.following_count }}</span>
</div>
<div>
<p>{{ i18n.ts.followers }}</p>
<span>{{ user.followersCount }}</span>
<span>{{ user.follower_count }}</span>
</div>
</div>
<div class="follow-button-container">
@ -127,19 +125,20 @@
</template>
<script lang="ts" setup>
import { onMounted } from "vue";
import * as Acct from "calckey-js/built/acct";
import type * as misskey from "calckey-js";
import { computed, onMounted } from "vue";
import MkFollowButton from "@/components/MkFollowButton.vue";
import { userPage } from "@/filters/user";
import XShowMoreButton from "@/components/MkShowMoreButton.vue";
import * as os from "@/os";
import { $i } from "@/account";
import { i18n } from "@/i18n";
import { packed, endpoints } from "magnetar-common";
import { host as localHost } from "@/config";
import { toUnicode } from "punycode";
const props = defineProps<{
showing: boolean;
q: string;
userTag: string | { username: string; host?: string };
source: HTMLElement;
}>();
@ -150,40 +149,51 @@ const emit = defineEmits<{
}>();
const zIndex = os.claimZIndex("middle");
let user = $ref<misskey.entities.UserDetailed | null>(null);
let user = $ref<packed.PackUserMaybeAll | null>(null);
let top = $ref(0);
let left = $ref(0);
let isLong = $ref(false);
let isLong = computed(() => {
if (!user?.description) return;
return (
user.description.split("\n").length > 9 || user.description.length > 400
);
});
let collapsed = $ref(!isLong);
onMounted(() => {
if (typeof props.q === "object") {
user = props.q;
isLong =
user.description.split("\n").length > 9 ||
user.description.length > 400;
} else {
const query = props.q.startsWith("@")
? Acct.parse(props.q.substr(1))
: { userId: props.q };
const options = { detail: true, profile: true, relation: true };
os.api("users/show", query).then((res) => {
if (!props.showing) return;
user = res;
isLong =
user.description.split("\n").length > 9 ||
user.description.length > 400;
debugger;
if (typeof props.userTag === "object") {
const canonical =
!props.userTag.host || props.userTag.host === localHost
? `${props.userTag.username}`
: `${props.userTag.username}@${toUnicode(props.userTag.host)}`;
os.magApi(endpoints.GetUserByAcct, options, {
user_acct: canonical,
}).then((u) => {
user = u;
});
} else {
const acctQuery = props.userTag.startsWith("@");
const apiCall = acctQuery
? os.magApi(endpoints.GetUserByAcct, options, {
user_acct: props.userTag,
})
: os.magApi(endpoints.GetUserById, options, {
user_id: props.userTag,
});
apiCall.then((u) => (user = u));
}
const rect = props.source.getBoundingClientRect();
const x =
rect.left + props.source.offsetWidth / 2 - 300 / 2 + window.pageXOffset;
const y = rect.top + props.source.offsetHeight + window.pageYOffset;
top = y;
left = x;
left = rect.left + props.source.offsetWidth / 2 - 300 / 2 + window.scrollX;
top = rect.top + props.source.offsetHeight + window.scrollY;
});
</script>

View File

@ -5,12 +5,12 @@
style="margin-right: 8px"
/>
{{ i18n.ts.accountMoved }}
<MkMention class="link" :username="acct" :host="host" />
<MagMention class="link" :username="acct" :host="host" />
</div>
</template>
<script lang="ts" setup>
import MkMention from "./MkMention.vue";
import MagMention from "./MagMention.vue";
import { i18n } from "@/i18n";
defineProps<{

View File

@ -176,9 +176,7 @@
<I18n :src="softMuteReasonI18nSrc(muted.what)" tag="small">
<template #name>
<MkA
v-user-preview="
magTransMap(note, 'user', 'userId', (u) => u.id)
"
v-user-preview="note.user.id"
class="name"
:to="userPage(note.user)"
>

View File

@ -3,7 +3,7 @@ import * as mfm from "mfm-js";
import type { VNode } from "vue";
import MkUrl from "@/components/global/MkUrl.vue";
import MkLink from "@/components/MkLink.vue";
import MkMention from "@/components/MkMention.vue";
import MagMention from "@/components/MagMention.vue";
import { concat } from "@/scripts/array";
import MkFormula from "@/components/MkFormula.vue";
import MkCode from "@/components/MkCode.vue";
@ -476,15 +476,15 @@ export default defineComponent({
case "mention": {
return [
h(MkMention, {
h(MagMention, {
key: Math.random(),
username: token.props.username,
host:
(token.props.host == null &&
this.author &&
this.author.host != null
? this.author.host
: token.props.host) || host,
username: token.props.username,
}),
];
}

View File

@ -9,7 +9,6 @@
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import MkButton from "../MkButton.vue";
import * as os from "@/os";
import { CounterVarBlock } from "@/scripts/hpml/block";
import { Hpml } from "@/scripts/hpml/evaluator";

View File

@ -4,7 +4,7 @@ import { popup } from "@/os";
export class UserPreview {
private el;
private user;
private user: string | { username: string; host?: string };
private showTimer;
private hideTimer;
private checkTimer;
@ -26,11 +26,11 @@ export class UserPreview {
popup(
defineAsyncComponent(
() => import("@/components/MkUserPreview.vue")
() => import("@/components/MagUserPreview.vue")
),
{
showing,
q: this.user,
userTag: this.user,
source: this.el,
},
{

View File

@ -127,7 +127,7 @@ export function magIsRenote(
note: packed.PackNoteMaybeFull | Misskey.entities.Note
): boolean {
return (
magTransProperty(note, "renoted_note", "renote") != null &&
(magTransProperty(note, "renoted_note", "renote") || null) !== null &&
(note.text || null) === null &&
magTransProperty(note, "file_ids", "fileIds").length === 0 &&
(note.poll || null) === null

View File

@ -3,8 +3,8 @@ import type { PackUserMaybeAll } from "../packed/PackUserMaybeAll";
import type { UserByIdReq } from "../UserByIdReq";
export const GetUserByAcct = {
endpoint: "/users/by-acct/:user_id",
pathParams: ["user_id"] as ["user_id"],
endpoint: "/users/by-acct/:user_acct",
pathParams: ["user_acct"] as ["user_acct"],
method: "GET" as "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
request: undefined as unknown as UserByIdReq,
response: undefined as unknown as PackUserMaybeAll

View File

@ -76,7 +76,7 @@ pub struct GetManyUsersById;
#[derive(Endpoint)]
#[endpoint(
endpoint = "/users/by-acct/:user_id",
endpoint = "/users/by-acct/:user_acct",
method = Method::GET,
request = "UserByIdReq",
response = "PackUserMaybeAll"

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use ts_rs::TS;
use crate::types::Id;
@ -21,3 +22,19 @@ pack!(PackEmojiBase, Required<Id> as id & Required<EmojiBase> as emoji);
#[ts(export)]
#[repr(transparent)]
pub struct EmojiContext(pub Vec<PackEmojiBase>);
impl EmojiContext {
pub fn extend_from(&mut self, more: &[PackEmojiBase]) {
let existing = self
.0
.iter()
.map(|e| e.emoji.0.shortcode.clone())
.collect::<HashSet<_>>();
self.0.extend(
more.iter()
.filter(|&e| existing.contains(&e.emoji.0.shortcode))
.cloned(),
);
}
}

View File

@ -28,10 +28,10 @@ pub enum SpeechTransform {
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[ts(export)]
pub struct ProfileField {
name: String,
value: String,
value_mm: Option<MmXml>,
verified_at: Option<DateTime<Utc>>,
pub name: String,
pub value: String,
pub value_mm: Option<MmXml>,
pub verified_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]

View File

@ -11,8 +11,6 @@ use crate::web::auth::AuthState;
use axum::middleware::from_fn_with_state;
use axum::routing::get;
use axum::Router;
use serde::de::{DeserializeOwned, Error};
use serde::{Deserialize, Deserializer};
use std::sync::Arc;
pub fn create_api_router(service: Arc<MagnetarService>) -> Router {

View File

@ -25,7 +25,7 @@ pub async fn handle_note(
attachments: attachments.unwrap_or_default(),
with_context: context.unwrap_or_default(),
}
.fetch_single(&ctx, self_user.as_deref(), &id)
.fetch_single(&ctx, &id)
.await?
.ok_or(ObjectNotFound(id))?;

View File

@ -9,46 +9,32 @@ use axum::Json;
use futures::StreamExt;
use futures_util::TryStreamExt;
use itertools::Itertools;
use magnetar_common::util::lenient_parse_tag;
use magnetar_common::util::lenient_parse_tag_decode;
use magnetar_sdk::endpoints::user::{
GetManyUsersById, GetUserByAcct, GetUserById, GetUserSelf, ManyUsersByIdReq, UserByIdReq,
UserSelfReq,
GetManyUsersById, GetUserByAcct, GetUserById, GetUserSelf, ManyUsersByIdReq,
};
use magnetar_sdk::endpoints::{Req, Res};
use std::collections::HashMap;
use std::sync::Arc;
pub async fn handle_user_info_self(
Query(UserSelfReq {
detail: _,
pins: _,
profile: _,
secrets: _,
}): Query<Req<GetUserSelf>>,
Query(req): Query<Req<GetUserSelf>>,
State(service): State<Arc<MagnetarService>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<Res<GetUserSelf>>, ApiError> {
// TODO: Extended properties!
let ctx = PackingContext::new(service, Some(user.clone())).await?;
let user = UserModel.base_from_existing(&ctx, user.as_ref()).await?;
Ok(Json(user.into()))
let user = UserModel
.self_full_from_base(&ctx, user.as_ref(), &req, None, None)
.await?;
Ok(Json(user))
}
pub async fn handle_user_info(
Path(id): Path<String>,
Query(UserByIdReq {
detail: _,
pins: _,
profile: _,
relation: _,
auth: _,
}): Query<Req<GetUserById>>,
Query(req): Query<Req<GetUserById>>,
State(service): State<Arc<MagnetarService>>,
MaybeUser(self_user): MaybeUser,
) -> Result<Json<Res<GetUserById>>, ApiError> {
// TODO: Extended properties!
let ctx = PackingContext::new(service.clone(), self_user).await?;
let user_model = service
.db
@ -56,25 +42,19 @@ pub async fn handle_user_info(
.await?
.ok_or(ObjectNotFound(id))?;
let user = UserModel.base_from_existing(&ctx, &user_model).await?;
Ok(Json(user.into()))
let user = UserModel
.foreign_full_from_base(&ctx, &user_model, &req, None, None)
.await?;
Ok(Json(user))
}
pub async fn handle_user_info_by_acct(
Path(tag_str): Path<String>,
Query(UserByIdReq {
detail: _,
pins: _,
profile: _,
relation: _,
auth: _,
}): Query<Req<GetUserByAcct>>,
Query(req): Query<Req<GetUserByAcct>>,
State(service): State<Arc<MagnetarService>>,
MaybeUser(self_user): MaybeUser,
) -> Result<Json<Res<GetUserByAcct>>, ApiError> {
// TODO: Extended properties!
let mut tag = lenient_parse_tag(&tag_str)?;
let mut tag = lenient_parse_tag_decode(&tag_str)?;
if matches!(&tag.host, Some(host) if host == &service.config.networking.host) {
tag.host = None;
}
@ -86,8 +66,10 @@ pub async fn handle_user_info_by_acct(
.await?
.ok_or(ObjectNotFound(tag_str))?;
let user = UserModel.base_from_existing(&ctx, &user_model).await?;
Ok(Json(user.into()))
let user = UserModel
.foreign_full_from_base(&ctx, &user_model, &req, None, None)
.await?;
Ok(Json(user))
}
pub async fn handle_user_by_id_many(
@ -109,7 +91,7 @@ pub async fn handle_user_by_id_many(
let futures = users
.iter()
.map(|u| user_model.base_from_existing(&ctx, u))
.map(|u| user_model.base_from_existing(&ctx, u, None))
.collect::<Vec<_>>();
let users_proc = futures::stream::iter(futures)

View File

@ -6,7 +6,8 @@ use magnetar_sdk::types::instance::InstanceTicker;
use magnetar_sdk::types::note::PackNoteMaybeFull;
use magnetar_sdk::types::user::{
AvatarDecoration, PackSecurityKeyBase, ProfileField, SecurityKeyBase, SpeechTransform,
UserBase, UserDetailExt, UserProfileExt, UserProfilePinsEx, UserRelationExt, UserSecretsExt,
UserAuthOverviewExt, UserBase, UserDetailExt, UserProfileExt, UserProfilePinsEx,
UserRelationExt, UserSecretsExt,
};
use magnetar_sdk::types::MmXml;
use url::Url;
@ -76,8 +77,7 @@ pub struct UserProfileExtSource<'a> {
pub user: &'a ck::user::Model,
pub profile: &'a ck::user_profile::Model,
pub profile_fields: &'a Vec<ProfileField>,
pub banner_url: Option<&'a Url>,
pub banner: Option<&'a ck::drive_file::Model>,
pub banner: Option<&'a PackDriveFileBase>,
pub description_mm: Option<&'a MmXml>,
pub relation: Option<&'a UserRelationExt>,
}
@ -89,7 +89,6 @@ impl PackType<UserProfileExtSource<'_>> for UserProfileExt {
user,
profile,
profile_fields,
banner_url,
banner,
description_mm,
relation,
@ -97,7 +96,9 @@ impl PackType<UserProfileExtSource<'_>> for UserProfileExt {
) -> Self {
let follow_visibility = match profile.ff_visibility {
UserProfileFfvisibilityEnum::Public => true,
UserProfileFfvisibilityEnum::Followers => relation.is_some_and(|r| r.follows_you),
UserProfileFfvisibilityEnum::Followers => {
relation.is_some_and(|r| r.follows_you) || context.is_self(user)
}
UserProfileFfvisibilityEnum::Private => false,
} || context.is_self(user);
@ -119,8 +120,8 @@ impl PackType<UserProfileExtSource<'_>> for UserProfileExt {
url: profile.url.clone(),
moved_to_uri: user.moved_to_uri.clone(),
also_known_as: user.also_known_as.clone(),
banner_url: banner_url.map(Url::to_string),
banner_blurhash: banner.and_then(|b| b.blurhash.clone()),
banner_url: banner.and_then(|b| b.file.0.url.to_owned()),
banner_blurhash: banner.and_then(|b| b.file.0.blurhash.clone()),
has_public_reactions: profile.public_reactions,
}
}
@ -238,3 +239,13 @@ impl PackType<UserSecretsExtSource<'_>> for UserSecretsExt {
}
}
}
impl PackType<&ck::user_profile::Model> for UserAuthOverviewExt {
fn extract(_context: &PackingContext, data: &ck::user_profile::Model) -> Self {
UserAuthOverviewExt {
has_passwordless_login: data.use_password_less_login,
has_security_keys: data.security_keys_available,
has_two_factor_enabled: data.two_factor_enabled,
}
}
}

View File

@ -24,6 +24,7 @@ impl Default for ProcessingLimits {
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
enum UserRelationship {
Follow,
FollowRequest,
Mute,
Block,
RenoteMute,
@ -120,6 +121,12 @@ impl PackingContext {
.get_follower_status(&link.from, &link.to)
.await?
.is_some(),
UserRelationship::FollowRequest => self
.service
.db
.get_follow_request_status(&link.from, &link.to)
.await?
.is_some(),
UserRelationship::Mute => self
.service
.db

View File

@ -19,6 +19,8 @@ pub enum PackError {
#[error("Calckey database wrapper error: {0}")]
CalckeyDbError(#[from] CalckeyDbError),
#[error("Emoji cache error: {0}")]
DataError(String),
#[error("Emoji cache error: {0}")]
EmojiCacheError(#[from] EmojiCacheError),
#[error("Instance cache error: {0}")]
InstanceMetaCacheError(#[from] InstanceMetaCacheError),

View File

@ -7,7 +7,7 @@ use crate::model::{PackType, PackingContext, UserRelationship};
use compact_str::CompactString;
use either::Either;
use futures_util::future::try_join_all;
use futures_util::TryFutureExt;
use futures_util::{StreamExt, TryFutureExt, TryStreamExt};
use magnetar_calckey_model::ck::sea_orm_active_enums::NoteVisibilityEnum;
use magnetar_calckey_model::emoji::EmojiTag;
use magnetar_calckey_model::note_model::{
@ -197,7 +197,7 @@ impl NoteModel {
note_data: &NoteData,
) -> PackResult<PackNoteBase> {
let Required(ref user) = UserModel
.base_from_existing(ctx, &note_data.user)
.base_from_existing(ctx, &note_data.user, None)
.await?
.user;
@ -378,7 +378,6 @@ impl NoteModel {
async fn extract_poll(
&self,
ctx: &PackingContext,
as_user: Option<&ck::user::Model>,
note: &ck::note::Model,
) -> PackResult<Option<PackPollBase>> {
if !note.has_poll {
@ -391,7 +390,7 @@ impl NoteModel {
return Ok(None);
};
let votes = match as_user {
let votes = match ctx.self_user.as_deref() {
Some(u) => Some(poll_resolver.get_poll_votes_by(&note.id, &u.id).await?),
None => None,
};
@ -406,13 +405,12 @@ impl NoteModel {
&self,
ctx: &PackingContext,
drive_model: &DriveModel,
as_user: Option<&ck::user::Model>,
note_data: &NoteData,
) -> PackResult<PackNoteMaybeAttachments> {
let (PackNoteBase { id, note }, attachments_pack, poll_pack) = try_join!(
self.extract_base(ctx, note_data),
self.extract_attachments(ctx, drive_model, &note_data.note),
self.extract_poll(ctx, as_user, &note_data.note)
self.extract_poll(ctx, &note_data.note)
)?;
Ok(PackNoteMaybeAttachments::pack_from((
@ -431,40 +429,17 @@ impl NoteModel {
)))
}
pub async fn fetch_single(
async fn pack_full_single(
&self,
ctx: &PackingContext,
as_user: Option<&ck::user::Model>,
id: &str,
) -> PackResult<Option<PackNoteMaybeFull>> {
let note_resolver = ctx.service.db.get_note_resolver();
let Some(note) = note_resolver
.get_one(&NoteResolveOptions {
ids: Some(vec![id.to_owned()]),
visibility_filter: Box::new(
NoteVisibilityFilterModel
.new_note_visibility_filter(as_user.map(ck::user::Model::get_id)),
),
time_range: None,
with_user: self.with_context,
with_reply_target: self.with_context,
with_renote_target: self.with_context,
with_interactions_from: self
.with_context
.then(|| as_user.map(ck::user::Model::get_id).map(str::to_string))
.flatten(),
})
.await?
else {
return Ok(None);
};
note: NoteData,
) -> PackResult<PackNoteMaybeFull> {
let drive_model = DriveModel;
let reply_target = async {
match note.reply.as_ref() {
Some(r) if self.with_context => self
.pack_single_attachments(ctx, &drive_model, as_user, r)
.pack_single_attachments(ctx, &drive_model, r)
.await
.map(Some),
_ => Ok(None),
@ -474,7 +449,7 @@ impl NoteModel {
let renote_target = async {
match note.renote.as_ref() {
Some(r) if self.with_context => self
.pack_single_attachments(ctx, &drive_model, as_user, r)
.pack_single_attachments(ctx, &drive_model, r)
.await
.map(Some),
_ => Ok(None),
@ -491,7 +466,7 @@ impl NoteModel {
reply_target_pack,
renote_target_pack,
) = try_join!(
self.pack_single_attachments(ctx, &drive_model, as_user, &note),
self.pack_single_attachments(ctx, &drive_model, &note),
reply_target,
renote_target
)?;
@ -506,12 +481,89 @@ impl NoteModel {
)
});
Ok(Some(PackNoteMaybeFull::pack_from((
Ok(PackNoteMaybeFull::pack_from((
id,
note,
user_context,
attachment,
Optional(detail),
))))
)))
}
pub async fn fetch_single(
&self,
ctx: &PackingContext,
id: &str,
) -> PackResult<Option<PackNoteMaybeFull>> {
let note_resolver = ctx.service.db.get_note_resolver();
let Some(note) = note_resolver
.get_one(&NoteResolveOptions {
ids: Some(vec![id.to_owned()]),
visibility_filter: Box::new(NoteVisibilityFilterModel.new_note_visibility_filter(
ctx.self_user.as_deref().map(ck::user::Model::get_id),
)),
time_range: None,
with_user: self.with_context,
with_reply_target: self.with_context,
with_renote_target: self.with_context,
with_interactions_from: self
.with_context
.then(|| {
ctx.self_user
.as_deref()
.map(ck::user::Model::get_id)
.map(str::to_string)
})
.flatten(),
only_pins_from: None,
})
.await?
else {
return Ok(None);
};
Ok(Some(self.pack_full_single(ctx, note).await?))
}
pub async fn fetch_pins(
&self,
ctx: &PackingContext,
pin_user: &ck::user::Model,
) -> PackResult<Vec<PackNoteMaybeFull>> {
let note_resolver = ctx.service.db.get_note_resolver();
let notes = note_resolver
.get_many(&NoteResolveOptions {
ids: None,
visibility_filter: Box::new(NoteVisibilityFilterModel.new_note_visibility_filter(
ctx.self_user.as_deref().map(ck::user::Model::get_id),
)),
time_range: None,
with_user: self.with_context,
with_reply_target: self.with_context,
with_renote_target: self.with_context,
with_interactions_from: self
.with_context
.then(|| {
ctx.self_user
.as_deref()
.map(ck::user::Model::get_id)
.map(str::to_string)
})
.flatten(),
only_pins_from: Some(pin_user.id.clone()),
})
.await?;
let fut_iter = notes
.into_iter()
.map(|note| self.pack_full_single(ctx, note));
let processed = futures::stream::iter(fut_iter)
.buffered(10)
.err_into::<PackError>()
.try_collect::<Vec<_>>()
.await?;
Ok(processed)
}
}

View File

@ -1,19 +1,35 @@
use crate::model::data::user::UserBaseSource;
use crate::model::data::user::{UserBaseSource, UserProfileExtSource};
use crate::model::processing::drive::DriveModel;
use crate::model::processing::emoji::EmojiModel;
use crate::model::processing::{get_mm_token_emoji, PackResult};
use crate::model::{PackType, PackingContext};
use crate::model::processing::note::NoteModel;
use crate::model::processing::{get_mm_token_emoji, PackError, PackResult};
use crate::model::{PackType, PackingContext, UserRelationship};
use either::Either;
use futures_util::future::OptionFuture;
use magnetar_calckey_model::ck;
use magnetar_sdk::endpoints::user::{UserByIdReq, UserSelfReq};
use magnetar_sdk::mmm::Token;
use magnetar_sdk::types::drive::PackDriveFileBase;
use magnetar_sdk::types::emoji::EmojiContext;
use magnetar_sdk::types::instance::InstanceTicker;
use magnetar_sdk::types::user::{PackUserBase, UserBase};
use magnetar_sdk::types::user::{
PackSecurityKeyBase, PackUserBase, PackUserMaybeAll, PackUserSelfMaybeAll, ProfileField,
SecurityKeyBase, UserAuthOverviewExt, UserBase, UserDetailExt, UserProfileExt,
UserProfilePinsEx, UserRelationExt, UserSecretsExt,
};
use magnetar_sdk::types::{Id, MmXml};
use magnetar_sdk::{mmm, Packed, Required};
use magnetar_sdk::{mmm, Optional, Packed, Required};
use serde::{Deserialize, Serialize};
use tokio::{join, try_join};
use tracing::warn;
use url::Url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldRaw<'a> {
name: &'a str,
value: &'a str,
}
pub struct UserModel;
impl UserModel {
@ -21,6 +37,12 @@ impl UserModel {
mmm::Context::default().parse_ui(user.name.as_deref().unwrap_or(&user.username))
}
pub fn tokenize_description(&self, user: &ck::user_profile::Model) -> Option<Token> {
user.description
.as_deref()
.map(|d| mmm::Context::default().parse_inline(d))
}
pub fn get_effective_avatar_url(
&self,
ctx: &PackingContext,
@ -56,15 +78,17 @@ impl UserModel {
&self,
ctx: &PackingContext,
user: &ck::user::Model,
hint_avatar_file: Option<&ck::drive_file::Model>,
) -> PackResult<PackUserBase> {
let drive_file_pack = DriveModel;
let avatar = match &user.avatar_id {
Some(av_id) => drive_file_pack.get_cached_base(ctx, av_id).await?,
None => None,
let avatar = match (hint_avatar_file, &user.avatar_id) {
(Some(avatar_file), _) => Some(drive_file_pack.pack_existing(ctx, avatar_file)),
(None, Some(av_id)) => drive_file_pack.get_cached_base(ctx, av_id).await?,
_ => None,
};
let avatar_url = &self.get_effective_avatar_url(ctx, user, avatar.as_ref())?;
let username_mm = self.tokenize_username(&user);
let username_mm = self.tokenize_username(user);
let emoji_model = EmojiModel;
let shortcodes = emoji_model.deduplicate_emoji(ctx, get_mm_token_emoji(&username_mm));
@ -100,4 +124,305 @@ impl UserModel {
Required(base),
)))
}
async fn get_profile(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
) -> PackResult<ck::user_profile::Model> {
ctx.service
.db
.get_user_profile_by_id(&user.id)
.await?
.ok_or_else(|| PackError::DataError("Missing user profile".to_string()))
}
pub async fn profile_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
profile: &ck::user_profile::Model,
relation: Option<&UserRelationExt>,
emoji_out: &mut EmojiContext,
hint_banner_file: Option<&ck::drive_file::Model>,
) -> PackResult<UserProfileExt> {
let drive_file_pack = DriveModel;
let banner = match (hint_banner_file, &user.banner_id) {
(Some(banner_file), _) => Some(drive_file_pack.pack_existing(ctx, banner_file)),
(None, Some(av_id)) => drive_file_pack.get_cached_base(ctx, av_id).await?,
_ => None,
};
let description_mm = self.tokenize_description(&profile);
let fields = Vec::<ProfileFieldRaw>::deserialize(&profile.fields)?;
let parser = mmm::Context::new(2);
let profile_fields = fields
.into_iter()
.map(|f| {
let tok = parser.parse_profile_fields(&f.value);
ProfileField {
name: f.name.to_string(),
value: f.value.to_string(),
value_mm: mmm::to_xml_string(&tok).map(MmXml).ok(),
verified_at: None,
}
})
.collect::<Vec<_>>();
if let Some(desc_mm) = &description_mm {
let emoji_model = EmojiModel;
let shortcodes = emoji_model.deduplicate_emoji(ctx, get_mm_token_emoji(&desc_mm));
let emojis = emoji_model
.fetch_many_emojis(ctx, &shortcodes, user.host.as_deref())
.await?;
emoji_out.extend_from(&emojis);
}
Ok(UserProfileExt::extract(
ctx,
UserProfileExtSource {
user,
profile: &profile,
profile_fields: &profile_fields,
banner: banner.as_ref(),
description_mm: description_mm
.and_then(|desc_mm| mmm::to_xml_string(&desc_mm).map(MmXml).ok())
.as_ref(),
relation,
},
))
}
pub async fn secrets_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
profile: &ck::user_profile::Model,
) -> PackResult<UserSecretsExt> {
let secrets = ctx
.service
.db
.get_user_security_keys_by_id(&user.id)
.await?
.into_iter()
.map(|k| {
PackSecurityKeyBase::pack_from((
Required(Id::from(&k.id)),
Required(SecurityKeyBase {
name: k.name,
last_used_at: Some(k.last_used.into()),
}),
))
})
.collect::<Vec<_>>();
Ok(UserSecretsExt::extract(ctx, (profile, &secrets)))
}
pub async fn self_full_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
req: &UserSelfReq,
hint_avatar_file: Option<&ck::drive_file::Model>,
hint_banner_file: Option<&ck::drive_file::Model>,
) -> PackResult<PackUserSelfMaybeAll> {
let should_fetch_profile =
req.profile.unwrap_or_default() || req.secrets.unwrap_or_default();
let profile_raw_promise =
OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user)));
let (base_res, profile_res) = join!(
self.base_from_existing(ctx, user, hint_avatar_file),
profile_raw_promise
);
let mut base = base_res?;
let profile_raw = profile_res.transpose()?;
let detail = req
.detail
.unwrap_or_default()
.then(|| UserDetailExt::extract(ctx, user));
let profile = OptionFuture::from(req.profile.unwrap_or_default().then(|| {
self.profile_from_base(
ctx,
user,
profile_raw.as_ref().unwrap(),
None,
&mut base.user.0.emojis,
hint_banner_file,
)
}));
let note_model = NoteModel {
with_context: true,
attachments: true,
};
let pins = OptionFuture::from(
req.pins
.unwrap_or_default()
.then(|| note_model.fetch_pins(ctx, user)),
);
let secrets = OptionFuture::from(
req.secrets
.unwrap_or_default()
.then(|| self.secrets_from_base(ctx, user, profile_raw.as_ref().unwrap())),
);
let (profile_res, pins_res, secrets_res) = join!(profile, pins, secrets);
let profile_resolved = profile_res.transpose()?;
let pins_resolved = pins_res
.transpose()?
.map(|notes| UserProfilePinsEx::extract(ctx, &notes));
let secrets_resolved = secrets_res.transpose()?;
Ok(PackUserSelfMaybeAll {
id: base.id,
user: base.user,
profile: Optional(profile_resolved),
pins: Optional(pins_resolved),
detail: Optional(detail),
secrets: Optional(secrets_resolved),
})
}
pub async fn relations_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
) -> PackResult<UserRelationExt> {
let Some(me_user) = ctx.self_user.as_deref() else {
return Ok(UserRelationExt {
follows_you: false,
you_follow: false,
you_request_follow: false,
they_request_follow: false,
blocks_you: false,
you_block: false,
mute: false,
mute_renotes: false,
});
};
let me = Either::Right(me_user);
let them = Either::Right(user);
let (
follows_you,
you_follow,
you_request_follow,
they_request_follow,
blocks_you,
you_block,
mute,
mute_renotes,
) = try_join!(
ctx.is_relationship_between(them, me, UserRelationship::Follow),
ctx.is_relationship_between(me, them, UserRelationship::Follow),
ctx.is_relationship_between(them, me, UserRelationship::FollowRequest),
ctx.is_relationship_between(me, them, UserRelationship::FollowRequest),
ctx.is_relationship_between(them, me, UserRelationship::Block),
ctx.is_relationship_between(me, them, UserRelationship::Block),
ctx.is_relationship_between(me, them, UserRelationship::Mute),
ctx.is_relationship_between(me, them, UserRelationship::RenoteMute)
)?;
Ok(UserRelationExt {
follows_you,
you_follow,
you_request_follow,
they_request_follow,
blocks_you,
you_block,
mute,
mute_renotes,
})
}
pub fn auth_overview_from_profile(
&self,
ctx: &PackingContext,
profile: &ck::user_profile::Model,
) -> PackResult<UserAuthOverviewExt> {
Ok(UserAuthOverviewExt::extract(ctx, profile))
}
pub async fn foreign_full_from_base(
&self,
ctx: &PackingContext,
user: &ck::user::Model,
req: &UserByIdReq,
hint_avatar_file: Option<&ck::drive_file::Model>,
hint_banner_file: Option<&ck::drive_file::Model>,
) -> PackResult<PackUserMaybeAll> {
let should_fetch_profile = req.profile.unwrap_or_default() || req.auth.unwrap_or_default();
let profile_raw_promise =
OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user)));
let (base, profile) = join!(
self.base_from_existing(ctx, user, hint_avatar_file),
profile_raw_promise
);
let mut base = base?;
let profile_raw = profile.transpose()?;
let detail = req
.detail
.unwrap_or_default()
.then(|| UserDetailExt::extract(ctx, user));
let profile = OptionFuture::from(req.profile.unwrap_or_default().then(|| {
self.profile_from_base(
ctx,
user,
profile_raw.as_ref().unwrap(),
None,
&mut base.user.0.emojis,
hint_banner_file,
)
}));
let note_model = NoteModel {
with_context: true,
attachments: true,
};
let pins = OptionFuture::from(
req.pins
.unwrap_or_default()
.then(|| note_model.fetch_pins(ctx, user)),
);
let relations = OptionFuture::from(
req.relation
.unwrap_or_default()
.then(|| self.relations_from_base(ctx, user)),
);
let auth = req
.auth
.unwrap_or_default()
.then(|| self.auth_overview_from_profile(ctx, profile_raw.as_ref().unwrap()))
.transpose()?;
let (profile_res, pins_res, relations_res) = join!(profile, pins, relations);
let profile_resolved = profile_res.transpose()?;
let pins_resolved = pins_res
.transpose()?
.map(|notes| UserProfilePinsEx::extract(ctx, &notes));
let relations_resolved = relations_res.transpose()?;
Ok(PackUserMaybeAll {
id: base.id,
user: base.user,
profile: Optional(profile_resolved),
pins: Optional(pins_resolved),
detail: Optional(detail),
relation: Optional(relations_resolved),
auth: Optional(auth),
})
}
}