Merge branch 'develop' into refactor/push-notification

This commit is contained in:
naskya 2024-05-04 13:22:34 +09:00
commit 66d59bd8ef
No known key found for this signature in database
GPG key ID: 712D413B3A9FED5C
9 changed files with 110 additions and 60 deletions

View file

@ -31,7 +31,7 @@ You can refer to [local-installation.md](./local-installation.md) to install the
1. Copy example config file
```sh
cp dev/config.example.env dev/config.env
# If you use container runtime other than Docker, you need to modify the "COMPOSE" variable
# If you use container runtime other than Podman, you need to modify the "COMPOSE" variable
# vim dev/config.env
```
1. Create `.config/default.yml` with the following content

View file

@ -2,8 +2,14 @@ use crate::database::{redis_conn, redis_key};
use redis::{Commands, RedisError};
use serde::{Deserialize, Serialize};
#[derive(strum::Display)]
pub enum Category {
#[strum(serialize = "fetchUrl")]
FetchUrl,
}
#[derive(thiserror::Error, Debug)]
pub enum CacheError {
pub enum Error {
#[error("Redis error: {0}")]
RedisError(#[from] RedisError),
#[error("Data serialization error: {0}")]
@ -12,15 +18,19 @@ pub enum CacheError {
DeserializeError(#[from] rmp_serde::decode::Error),
}
fn categorize(category: Category, key: &str) -> String {
format!("{}:{}", category, key)
}
fn prefix_key(key: &str) -> String {
redis_key(format!("cache:{}", key))
}
pub fn set_cache<V: for<'a> Deserialize<'a> + Serialize>(
pub fn set<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), CacheError> {
) -> Result<(), Error> {
redis_conn()?.set_ex(
prefix_key(key),
rmp_serde::encode::to_vec(&value)?,
@ -29,9 +39,7 @@ pub fn set_cache<V: for<'a> Deserialize<'a> + Serialize>(
Ok(())
}
pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(
key: &str,
) -> Result<Option<V>, CacheError> {
pub fn get<V: for<'a> Deserialize<'a> + Serialize>(key: &str) -> Result<Option<V>, Error> {
let serialized_value: Option<Vec<u8>> = redis_conn()?.get(prefix_key(key))?;
Ok(match serialized_value {
Some(v) => Some(rmp_serde::from_slice::<V>(v.as_ref())?),
@ -39,13 +47,35 @@ pub fn get_cache<V: for<'a> Deserialize<'a> + Serialize>(
})
}
pub fn delete_cache(key: &str) -> Result<(), CacheError> {
pub fn delete(key: &str) -> Result<(), Error> {
Ok(redis_conn()?.del(prefix_key(key))?)
}
pub fn set_one<V: for<'a> Deserialize<'a> + Serialize>(
category: Category,
key: &str,
value: &V,
expire_seconds: u64,
) -> Result<(), Error> {
set(&categorize(category, key), value, expire_seconds)
}
pub fn get_one<V: for<'a> Deserialize<'a> + Serialize>(
category: Category,
key: &str,
) -> Result<Option<V>, Error> {
get(&categorize(category, key))
}
pub fn delete_one(category: Category, key: &str) -> Result<(), Error> {
delete(&categorize(category, key))
}
// TODO: set_all(), get_all(), delete_all()
#[cfg(test)]
mod unit_test {
use super::{get_cache, set_cache};
use super::{get, set};
use pretty_assertions::assert_eq;
#[test]
@ -68,13 +98,13 @@ mod unit_test {
kind: "prime number".to_string(),
};
set_cache(key_1, &value_1, 1).unwrap();
set_cache(key_2, &value_2, 1).unwrap();
set_cache(key_3, &value_3, 1).unwrap();
set(key_1, &value_1, 1).unwrap();
set(key_2, &value_2, 1).unwrap();
set(key_3, &value_3, 1).unwrap();
let cached_value_1: Vec<i32> = get_cache(key_1).unwrap().unwrap();
let cached_value_2: String = get_cache(key_2).unwrap().unwrap();
let cached_value_3: Data = get_cache(key_3).unwrap().unwrap();
let cached_value_1: Vec<i32> = get(key_1).unwrap().unwrap();
let cached_value_2: String = get(key_2).unwrap().unwrap();
let cached_value_3: Data = get(key_3).unwrap().unwrap();
assert_eq!(value_1, cached_value_1);
assert_eq!(value_2, cached_value_2);
@ -83,9 +113,9 @@ mod unit_test {
// wait for the cache to expire
std::thread::sleep(std::time::Duration::from_millis(1100));
let expired_value_1: Option<Vec<i32>> = get_cache(key_1).unwrap();
let expired_value_2: Option<Vec<i32>> = get_cache(key_2).unwrap();
let expired_value_3: Option<Vec<i32>> = get_cache(key_3).unwrap();
let expired_value_1: Option<Vec<i32>> = get(key_1).unwrap();
let expired_value_2: Option<Vec<i32>> = get(key_2).unwrap();
let expired_value_3: Option<Vec<i32>> = get(key_3).unwrap();
assert!(expired_value_1.is_none());
assert!(expired_value_2.is_none());

View file

@ -2,5 +2,6 @@ pub use postgresql::db_conn;
pub use redis::key as redis_key;
pub use redis::redis_conn;
pub mod cache;
pub mod postgresql;
pub mod redis;

View file

@ -1,4 +1,4 @@
use crate::misc::redis_cache::{get_cache, set_cache, CacheError};
use crate::database::cache;
use crate::util::http_client;
use image::{io::Reader, ImageError, ImageFormat};
use nom_exif::{parse_jpeg_exif, EntryValue, ExifTag};
@ -8,7 +8,7 @@ use tokio::sync::Mutex;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Redis cache error: {0}")]
CacheErr(#[from] CacheError),
CacheErr(#[from] cache::Error),
#[error("Reqwest error: {0}")]
ReqwestErr(#[from] reqwest::Error),
#[error("Image decoding error: {0}")]
@ -50,11 +50,10 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
{
let _ = MTX_GUARD.lock().await;
let key = format!("fetchImage:{}", url);
attempted = get_cache::<bool>(&key)?.is_some();
attempted = cache::get_one::<bool>(cache::Category::FetchUrl, url)?.is_some();
if !attempted {
set_cache(&key, &true, 10 * 60)?;
cache::set_one(cache::Category::FetchUrl, url, &true, 10 * 60)?;
}
}
@ -109,7 +108,7 @@ pub async fn get_image_size_from_url(url: &str) -> Result<ImageSize, Error> {
#[cfg(test)]
mod unit_test {
use super::{get_image_size_from_url, ImageSize};
use crate::misc::redis_cache::delete_cache;
use crate::database::cache;
use pretty_assertions::assert_eq;
#[tokio::test]
@ -126,15 +125,15 @@ mod unit_test {
// Delete caches in case you run this test multiple times
// (should be disabled in CI tasks)
delete_cache(&format!("fetchImage:{}", png_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", png_url_3)).unwrap();
delete_cache(&format!("fetchImage:{}", rotated_jpeg_url)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_1)).unwrap();
delete_cache(&format!("fetchImage:{}", webp_url_2)).unwrap();
delete_cache(&format!("fetchImage:{}", ico_url)).unwrap();
delete_cache(&format!("fetchImage:{}", gif_url)).unwrap();
delete_cache(&format!("fetchImage:{}", mp3_url)).unwrap();
cache::delete_one(cache::Category::FetchUrl, png_url_1).unwrap();
cache::delete_one(cache::Category::FetchUrl, png_url_2).unwrap();
cache::delete_one(cache::Category::FetchUrl, png_url_3).unwrap();
cache::delete_one(cache::Category::FetchUrl, rotated_jpeg_url).unwrap();
cache::delete_one(cache::Category::FetchUrl, webp_url_1).unwrap();
cache::delete_one(cache::Category::FetchUrl, webp_url_2).unwrap();
cache::delete_one(cache::Category::FetchUrl, ico_url).unwrap();
cache::delete_one(cache::Category::FetchUrl, gif_url).unwrap();
cache::delete_one(cache::Category::FetchUrl, mp3_url).unwrap();
let png_size_1 = ImageSize {
width: 1024,

View file

@ -13,5 +13,4 @@ pub mod meta;
pub mod nyaify;
pub mod password;
pub mod reaction;
pub mod redis_cache;
pub mod remove_old_attestation_challenges;

View file

@ -71,7 +71,7 @@ import { foldNotifications } from "@/scripts/fold";
import { defaultStore } from "@/store";
const props = defineProps<{
includeTypes?: (typeof notificationTypes)[number][];
includeTypes?: (typeof notificationTypes)[number][] | null;
unreadOnly?: boolean;
}>();

View file

@ -44,6 +44,7 @@ const FIRE_THRESHOLD = defaultStore.state.pullToRefreshThreshold;
const RELEASE_TRANSITION_DURATION = 200;
const PULL_BRAKE_BASE = 1.5;
const PULL_BRAKE_FACTOR = 170;
const MAX_PULL_TAN_ANGLE = Math.tan((1 / 6) * Math.PI); // 30°
const pullStarted = ref(false);
const pullEnded = ref(false);
@ -53,6 +54,7 @@ const pullDistance = ref(0);
let disabled = false;
const supportPointerDesktop = false;
let startScreenY: number | null = null;
let startScreenX: number | null = null;
const rootEl = shallowRef<HTMLDivElement>();
let scrollEl: HTMLElement | null = null;
@ -72,11 +74,16 @@ function getScreenY(event) {
if (supportPointerDesktop) return event.screenY;
return event.touches[0].screenY;
}
function getScreenX(event) {
if (supportPointerDesktop) return event.screenX;
return event.touches[0].screenX;
}
function moveStart(event) {
if (!pullStarted.value && !isRefreshing.value && !disabled) {
pullStarted.value = true;
startScreenY = getScreenY(event);
startScreenX = getScreenX(event);
pullDistance.value = 0;
}
}
@ -117,6 +124,7 @@ async function closeContent() {
function moveEnd() {
if (pullStarted.value && !isRefreshing.value) {
startScreenY = null;
startScreenX = null;
if (pullEnded.value) {
pullEnded.value = false;
isRefreshing.value = true;
@ -146,11 +154,17 @@ function moving(event: TouchEvent | PointerEvent) {
moveEnd();
return;
}
if (startScreenY === null) {
startScreenY = getScreenY(event);
}
startScreenX ??= getScreenX(event);
startScreenY ??= getScreenY(event);
const moveScreenY = getScreenY(event);
const moveScreenX = getScreenX(event);
const moveHeight = moveScreenY - startScreenY!;
const moveWidth = moveScreenX - startScreenX!;
if (Math.abs(moveWidth / moveHeight) > MAX_PULL_TAN_ANGLE) {
if (Math.abs(moveWidth) > 30) pullStarted.value = false;
return;
}
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
if (pullDistance.value > 0) {

View file

@ -27,6 +27,7 @@
>
<swiper-slide>
<XNotifications
:key="'tab1'"
class="notifications"
:include-types="includeTypes"
:unread-only="false"
@ -34,16 +35,18 @@
</swiper-slide>
<swiper-slide>
<XNotifications
v-if="tab === 'reactions'"
:key="'tab2'"
class="notifications"
:include-types="['reaction']"
:unread-only="false"
/>
</swiper-slide>
<swiper-slide>
<XNotes :pagination="mentionsPagination" />
<XNotes v-if="tab === 'mentions'" :key="'tab3'" :pagination="mentionsPagination" />
</swiper-slide>
<swiper-slide>
<XNotes :pagination="directNotesPagination" />
<XNotes v-if="tab === 'directNotes'" :key="'tab4'" :pagination="directNotesPagination" />
</swiper-slide>
</swiper>
</MkSpacer>
@ -54,6 +57,7 @@
import { computed, ref, watch } from "vue";
import { Virtual } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/vue";
import type { Swiper as SwiperType } from "swiper/types";
import { notificationTypes } from "firefish-js";
import XNotifications from "@/components/MkNotifications.vue";
import XNotes from "@/components/MkNotes.vue";
@ -70,7 +74,7 @@ const tabs = ["all", "reactions", "mentions", "directNotes"];
const tab = ref(tabs[0]);
watch(tab, () => syncSlide(tabs.indexOf(tab.value)));
const includeTypes = ref<string[] | null>(null);
const includeTypes = ref<(typeof notificationTypes)[number][] | null>(null);
os.api("notifications/mark-all-as-read");
const MOBILE_THRESHOLD = 500;
@ -98,7 +102,7 @@ const directNotesPagination = {
function setFilter(ev) {
const typeItems = notificationTypes.map((t) => ({
text: i18n.t(`_notification._types.${t}`),
active: includeTypes.value && includeTypes.value.includes(t),
active: includeTypes.value?.includes(t),
action: () => {
includeTypes.value = [t];
},
@ -121,25 +125,23 @@ function setFilter(ev) {
}
const headerActions = computed(() =>
[
tab.value === "all"
? {
tab.value === "all"
? [
{
text: i18n.ts.filter,
icon: `${icon("ph-funnel")}`,
highlighted: includeTypes.value != null,
handler: setFilter,
}
: undefined,
tab.value === "all"
? {
},
{
text: i18n.ts.markAllAsRead,
icon: `${icon("ph-check")}`,
handler: () => {
os.apiWithDialog("notifications/mark-all-as-read");
},
}
: undefined,
].filter((x) => x !== undefined),
},
]
: [],
);
const headerTabs = computed(() => [
@ -172,18 +174,19 @@ definePageMetadata(
})),
);
let swiperRef = null;
let swiperRef: SwiperType | null = null;
function setSwiperRef(swiper) {
function setSwiperRef(swiper: SwiperType) {
swiperRef = swiper;
syncSlide(tabs.indexOf(tab.value));
}
function onSlideChange() {
tab.value = tabs[swiperRef.activeIndex];
if (tab.value !== tabs[swiperRef!.activeIndex])
tab.value = tabs[swiperRef!.activeIndex];
}
function syncSlide(index) {
swiperRef.slideTo(index);
function syncSlide(index: number) {
if (index !== swiperRef!.activeIndex) swiperRef!.slideTo(index);
}
</script>

View file

@ -265,14 +265,18 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: "ph-qr-code ph-bold ph-lg",
text: i18n.ts.getQrCode,
action: () => {
os.displayQrCode(`https://${host}/follow-me?acct=${user.username}`);
os.displayQrCode(
`https://${host}/follow-me?acct=${acct.toString(user)}`,
);
},
},
{
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.copyRemoteFollowUrl,
action: () => {
copyToClipboard(`https://${host}/follow-me?acct=${user.username}`);
copyToClipboard(
`https://${host}/follow-me?acct=${acct.toString(user)}`,
);
os.success();
},
},
@ -321,7 +325,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
icon: `${icon("ph-hand-waving")}`,
text: i18n.ts.remoteFollow,
action: () => {
router.push(`/follow-me?acct=${user.username}`);
router.push(`/follow-me?acct=${acct.toString(user)}`);
},
}
: undefined,