Compare commits

...

11 commits

6 changed files with 119 additions and 49 deletions

View file

@ -13,6 +13,7 @@ const StillImage = {
return { return {
stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches, stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches,
isAnimated: false, isAnimated: false,
imageTypeLabel: ''
} }
}, },
computed: { computed: {
@ -39,27 +40,22 @@ const StillImage = {
this.imageLoadError && this.imageLoadError() this.imageLoadError && this.imageLoadError()
}, },
detectAnimation (image) { detectAnimation (image) {
// If there are no file extensions, the mimetype isn't set, and no mediaproxy is available, we can't figure out
// the mimetype of the image.
const hasFileExtension = this.src.split('/').pop().includes('.') // TODO: Better check?
const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable
if (!hasFileExtension && this.mimetype === undefined && !mediaProxyAvailable) {
// harmless CORS errors without-- clean console with
if (!mediaProxyAvailable) {
// It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for // It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for
// people in need of reduced motion accessibility. As such, we'll consider those images animated if the user // people in need of reduced motion accessibility. As such, we'll consider those images animated if the user
// agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit. // agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
// Only for no media-proxy for now, since we're capturing gif, webp, and apng (are there others?)
// Since the canvas and images are not pixel-perfect matching (due to scaling),
// It makes the images jiggle on hover, which is not ideal for accessibility, methinks
this.isAnimated = true this.isAnimated = true
}
return return
} }
if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) {
this.isAnimated = true
return
}
// harmless CORS errors without-- clean console with
if (!mediaProxyAvailable) return
// Animated JPEGs?
if (!(this.src.endsWith('.webp') || this.src.endsWith('.png'))) return
// Browser Cache should ensure image doesn't get loaded twice if cache exists // Browser Cache should ensure image doesn't get loaded twice if cache exists
fetch(image.src, { fetch(image.src, {
referrerPolicy: 'same-origin' referrerPolicy: 'same-origin'
@ -68,12 +64,20 @@ const StillImage = {
// We don't need to read the whole file so only call it once // We don't need to read the whole file so only call it once
data.body.getReader().read() data.body.getReader().read()
.then(reader => { .then(reader => {
if (this.src.endsWith('.webp') && this.isAnimatedWEBP(reader.value)) { // Ordered from least to most intensive
if (this.isGIF(reader.value)) {
this.isAnimated = true this.isAnimated = true
this.setLabel('GIF')
return return
} }
if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) { if (this.isAnimatedWEBP(reader.value)) {
this.isAnimated = true this.isAnimated = true
this.setLabel('WEBP')
return
}
if (this.isAnimatedPNG(reader.value)) {
this.isAnimated = true
this.setLabel('APNG')
} }
}) })
}) })
@ -81,6 +85,23 @@ const StillImage = {
// this.imageLoadError && this.imageLoadError() // this.imageLoadError && this.imageLoadError()
}) })
}, },
setLabel (name) {
this.imageTypeLabel = name;
},
isGIF (data) {
// I am a perfectly sane individual
//
// GIF HEADER CHUNK
// === START HEADER ===
// 47 49 46 38 ("GIF8")
const gifHeader = [0x47, 0x49, 0x46];
for (let i = 0; i < 3; i++) {
if (data[i] !== gifHeader[i]) {
return false;
}
}
return true
},
isAnimatedWEBP (data) { isAnimatedWEBP (data) {
/** /**
* WEBP HEADER CHUNK * WEBP HEADER CHUNK
@ -114,15 +135,54 @@ const StillImage = {
const idatPos = str.indexOf('IDAT') const idatPos = str.indexOf('IDAT')
return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0) return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0)
}, },
drawThumbnail () { drawThumbnail() {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas;
if (!this.$refs.canvas) return if (!canvas) return;
const image = this.$refs.src
const width = image.naturalWidth const context = canvas.getContext('2d');
const height = image.naturalHeight const image = this.$refs.src;
canvas.width = width const parentElement = canvas.parentElement;
canvas.height = height
canvas.getContext('2d').drawImage(image, 0, 0, width, height) // Draw the quick, unscaled version first
context.drawImage(image, 0, 0, parentElement.clientWidth, parentElement.clientHeight);
// Use requestAnimationFrame to schedule the scaling to the next frame
requestAnimationFrame(() => {
// Compute scaling ratio between the natural dimensions of the image and its display dimensions
const scalingRatioWidth = parentElement.clientWidth / image.naturalWidth;
const scalingRatioHeight = parentElement.clientHeight / image.naturalHeight;
// Adjust for high-DPI displays
const ratio = window.devicePixelRatio || 1;
canvas.width = image.naturalWidth * scalingRatioWidth * ratio;
canvas.height = image.naturalHeight * scalingRatioHeight * ratio;
canvas.style.width = `${parentElement.clientWidth}px`;
canvas.style.height = `${parentElement.clientHeight}px`;
context.scale(ratio, ratio);
// Maintain the aspect ratio of the image
const imgAspectRatio = image.naturalWidth / image.naturalHeight;
const canvasAspectRatio = parentElement.clientWidth / parentElement.clientHeight;
let drawWidth, drawHeight;
if (imgAspectRatio > canvasAspectRatio) {
drawWidth = parentElement.clientWidth;
drawHeight = parentElement.clientWidth / imgAspectRatio;
} else {
drawHeight = parentElement.clientHeight;
drawWidth = parentElement.clientHeight * imgAspectRatio;
}
context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
// Draw the good one for realsies
const dx = (parentElement.clientWidth - drawWidth) / 2;
const dy = (parentElement.clientHeight - drawHeight) / 2;
context.drawImage(image, dx, dy, drawWidth, drawHeight);
});
} }
}, },
updated () { updated () {

View file

@ -1,9 +1,15 @@
<template> <template>
<div <div
ref="still-image"
class="still-image" class="still-image"
:class="{ animated: animated }" :class="{ animated: animated }"
:style="style" :style="style"
> >
<div
v-if="animated && imageTypeLabel"
class="image-type-label">
{{ imageTypeLabel }}
</div>
<canvas <canvas
v-if="animated" v-if="animated"
ref="canvas" ref="canvas"
@ -57,30 +63,26 @@
} }
} }
&.animated { .image-type-label {
&::before {
zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute; position: absolute;
top: 0.25em;
left: 0.25em;
line-height: 1; line-height: 1;
font-size: 0.7em; font-size: 0.6em;
top: 0.5em;
left: 0.5em;
background: rgba(127, 127, 127, 0.5); background: rgba(127, 127, 127, 0.5);
color: #fff; color: #fff;
display: block;
padding: 2px 4px; padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2; z-index: 2;
visibility: var(--_still-image-label-visibility, visible); visibility: var(--_still-image-label-visibility, visible);
} }
&.animated {
&:hover canvas { &:hover canvas {
display: none; display: none;
} }
&:hover::before { &:hover .image-type-label {
visibility: var(--_still-image-label-visibility, hidden); visibility: var(--_still-image-label-visibility, hidden);
} }

View file

@ -33,7 +33,7 @@
--_avatarShadowBox: var(--avatarStatusShadow); --_avatarShadowBox: var(--avatarStatusShadow);
--_avatarShadowFilter: var(--avatarStatusShadowFilter); --_avatarShadowFilter: var(--avatarStatusShadowFilter);
--_avatarShadowInset: var(--avatarStatusShadowInset); --_avatarShadowInset: var(--avatarStatusShadowInset);
--_still-image-label-visibility: hidden; // --_still-image-label-visibility: hidden;
display: inline-block; display: inline-block;
position: relative; position: relative;

View file

@ -35,6 +35,7 @@ const loaders = {
sk: () => import('./sk.json'), sk: () => import('./sk.json'),
te: () => import('./te.json'), te: () => import('./te.json'),
uk: () => import('./uk.json'), uk: () => import('./uk.json'),
vi: () => import('./vi.json'),
zh: () => import('./zh.json'), zh: () => import('./zh.json'),
zh_Hant: () => import('./zh_Hant.json') zh_Hant: () => import('./zh_Hant.json')
} }

View file

@ -37,11 +37,18 @@ const recentEmojis = {
getters: { getters: {
recentEmojis: (state, getters, rootState) => state.emojis.reduce((objects, displayText) => { recentEmojis: (state, getters, rootState) => state.emojis.reduce((objects, displayText) => {
const allEmojis = rootState.instance.emoji.concat(rootState.instance.customEmoji) let comparator = emoji => emoji.displayText === displayText
let emojiObject = allEmojis.find(emoji => emoji.displayText === displayText)
let emojiObject = rootState.instance.emoji.find(comparator)
if (emojiObject !== undefined) {
objects.push(emojiObject)
} else {
emojiObject = rootState.instance.customEmoji.find(comparator)
if (emojiObject !== undefined) { if (emojiObject !== undefined) {
objects.push(emojiObject) objects.push(emojiObject)
} }
}
return objects return objects
}, []), }, []),
}, },

View file

@ -8,7 +8,7 @@ const specialLanguageCodes = {
'zh': 'zh-Hans' 'zh': 'zh-Hans'
} }
const internalToBrowserLocale = code => specialLanguageCodes[code] || code const internalToBrowserLocale = fallbackCode => specialLanguageCodes[fallbackCode] || window.navigator.language || fallbackCode
const internalToBackendLocale = code => internalToBrowserLocale(code).replace('_', '-') const internalToBackendLocale = code => internalToBrowserLocale(code).replace('_', '-')