This commit is contained in:
Max Leiter 2022-05-23 02:27:10 -07:00
parent f37d82dd19
commit 52c13f49c1
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
17 changed files with 555 additions and 387 deletions

View file

@ -29,10 +29,36 @@ import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import Mentions from "./Mentions.vue";
import {computed, defineComponent, onBeforeUnmount, onMounted, ref} from "vue";
import {
computed,
provide,
defineComponent,
onBeforeUnmount,
onMounted,
ref,
Ref,
InjectionKey,
inject,
} from "vue";
import {useStore} from "../js/store";
import type {DebouncedFunc} from "lodash";
const imageViewerKey = Symbol() as InjectionKey<Ref<typeof ImageViewer | null>>;
const contextMenuKey = Symbol() as InjectionKey<Ref<typeof ContextMenu | null>>;
const confirmDialogKey = Symbol() as InjectionKey<Ref<typeof ConfirmDialog | null>>;
export const useImageViewer = () => {
return inject(imageViewerKey) as Ref<typeof ImageViewer | null>;
};
export const useContextMenu = () => {
return inject(contextMenuKey) as Ref<typeof ContextMenu | null>;
};
export const useConfirmDialog = () => {
return inject(confirmDialogKey) as Ref<typeof ConfirmDialog | null>;
};
export default defineComponent({
name: "App",
components: {
@ -51,6 +77,10 @@ export default defineComponent({
const contextMenu = ref(null);
const confirmDialog = ref(null);
provide(imageViewerKey, imageViewer);
provide(contextMenuKey, contextMenu);
provide(confirmDialogKey, confirmDialog);
const viewportClasses = computed(() => {
return {
notified: store.getters.highlightCount > 0,

View file

@ -3,10 +3,10 @@
<div
id="chat"
:class="{
'hide-motd': !$store.state.settings.motd,
'colored-nicks': $store.state.settings.coloredNicks,
'time-seconds': $store.state.settings.showSeconds,
'time-12h': $store.state.settings.use12hClock,
'hide-motd': store.state.settings.motd,
'colored-nicks': store.state.settings.coloredNicks,
'time-seconds': store.state.settings.showSeconds,
'time-12h': store.state.settings.use12hClock,
}"
>
<div
@ -47,7 +47,7 @@
/></span>
<MessageSearchForm
v-if="
$store.state.settings.searchEnabled &&
store.state.settings.searchEnabled &&
['channel', 'query'].includes(channel.type)
"
:network="network"
@ -71,7 +71,7 @@
<button
class="rt"
aria-label="Toggle user list"
@click="$store.commit('toggleUserlist')"
@click="store.commit('toggleUserlist')"
/>
</span>
</div>
@ -95,7 +95,7 @@
{'scroll-down-shown': !channel.scrolledToBottom},
]"
aria-label="Jump to recent messages"
@click="$refs.messageList.jumpToBottom()"
@click="messageList?.jumpToBottom()"
>
<div class="scroll-down-arrow" />
</div>
@ -110,11 +110,11 @@
</div>
</div>
<div
v-if="$store.state.currentUserVisibleError"
v-if="store.state.currentUserVisibleError"
id="user-visible-error"
@click="hideUserVisibleError"
>
{{ $store.state.currentUserVisibleError }}
{{ store.state.currentUserVisibleError }}
</div>
<ChatInput :network="network" :channel="channel" />
</div>
@ -133,8 +133,9 @@ import ListBans from "./Special/ListBans.vue";
import ListInvites from "./Special/ListInvites.vue";
import ListChannels from "./Special/ListChannels.vue";
import ListIgnored from "./Special/ListIgnored.vue";
import {Component, defineComponent, PropType} from "vue";
import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Component} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
export default defineComponent({
name: "Chat",
@ -151,92 +152,123 @@ export default defineComponent({
channel: {type: Object as PropType<ClientChan>, required: true},
focused: String,
},
computed: {
specialComponent(): Component {
switch (this.channel.special) {
setup(props) {
const store = useStore();
const messageList = ref<typeof MessageList>();
const topicInput = ref<HTMLInputElement | null>(null);
const specialComponent = computed(() => {
switch (props.channel.special) {
case "list_bans":
return ListBans;
return ListBans as Component;
case "list_invites":
return ListInvites;
return ListInvites as Component;
case "list_channels":
return ListChannels;
return ListChannels as Component;
case "list_ignored":
return ListIgnored;
return ListIgnored as Component;
}
return undefined;
},
},
watch: {
channel() {
this.channelChanged();
},
"channel.editTopic"(newValue) {
if (newValue) {
this.$nextTick(() => {
this.$refs.topicInput.focus();
});
const channelChanged = () => {
// Triggered when active channel is set or changed
props.channel.highlight = 0;
props.channel.unread = 0;
socket.emit("open", props.channel.id);
if (props.channel.usersOutdated) {
props.channel.usersOutdated = false;
socket.emit("names", {
target: props.channel.id,
});
}
};
const hideUserVisibleError = () => {
store.commit("currentUserVisibleError", null);
};
const editTopic = () => {
if (props.channel.type === "channel") {
props.channel.editTopic = true;
}
};
const saveTopic = () => {
props.channel.editTopic = false;
if (!topicInput.value) {
return;
}
const newTopic = topicInput.value.value;
if (props.channel.topic !== newTopic) {
const target = props.channel.id;
const text = `/raw TOPIC ${props.channel.name} :${newTopic}`;
socket.emit("input", {target, text});
}
};
const openContextMenu = (event: any) => {
eventbus.emit("contextmenu:channel", {
event: event,
channel: props.channel,
network: props.network,
});
};
const openMentions = (event: any) => {
eventbus.emit("mentions:toggle", {
event: event,
});
};
watch(props.channel, () => {
channelChanged();
});
const editTopicRef = ref(props.channel.editTopic);
watch(editTopicRef, (newTopic) => {
if (newTopic) {
nextTick(() => {
topicInput.value?.focus();
}).catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
});
}
},
},
mounted() {
this.channelChanged();
});
if (this.channel.editTopic) {
this.$nextTick(() => {
this.$refs.topicInput.focus();
});
}
},
methods: {
channelChanged() {
// Triggered when active channel is set or changed
this.channel.highlight = 0;
this.channel.unread = 0;
onMounted(() => {
channelChanged();
socket.emit("open", this.channel.id);
if (this.channel.usersOutdated) {
this.channel.usersOutdated = false;
socket.emit("names", {
target: this.channel.id,
if (props.channel.editTopic) {
nextTick(() => {
topicInput.value?.focus();
}).catch(() => {
// no-op
});
}
},
hideUserVisibleError() {
this.$store.commit("currentUserVisibleError", null);
},
editTopic() {
if (this.channel.type === "channel") {
this.channel.editTopic = true;
}
},
saveTopic() {
this.channel.editTopic = false;
const newTopic = this.$refs.topicInput.value;
});
if (this.channel.topic !== newTopic) {
const target = this.channel.id;
const text = `/raw TOPIC ${this.channel.name} :${newTopic}`;
socket.emit("input", {target, text});
}
},
openContextMenu(event) {
eventbus.emit("contextmenu:channel", {
event: event,
channel: this.channel,
network: this.network,
});
},
openMentions(event) {
eventbus.emit("mentions:toggle", {
event: event,
});
},
return {
store,
messageList,
topicInput,
specialComponent,
editTopicRef,
hideUserVisibleError,
editTopic,
saveTopic,
openContextMenu,
openMentions,
};
},
});
</script>

View file

@ -16,7 +16,7 @@
@blur="onBlur"
/>
<span
v-if="$store.state.serverConfiguration?.fileUpload"
v-if="store.state.serverConfiguration?.fileUpload"
id="upload-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Upload file"
@ -34,7 +34,7 @@
id="upload"
type="button"
aria-label="Upload file"
:disabled="!$store.state.isConnected"
:disabled="!store.state.isConnected"
/>
</span>
<span
@ -46,7 +46,7 @@
id="submit"
type="submit"
aria-label="Send message"
:disabled="!$store.state.isConnected"
:disabled="!store.state.isConnected"
/>
</span>
</form>
@ -60,8 +60,9 @@ import commands from "../js/commands/index";
import socket from "../js/socket";
import upload from "../js/upload";
import eventbus from "../js/eventbus";
import {defineComponent, PropType} from "vue";
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
const formattingHotkeys = {
"mod+k": "\x03",
@ -88,178 +89,103 @@ const bracketWraps = {
_: "_",
};
let autocompletionRef = null;
export default defineComponent({
name: "ChatInput",
props: {
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
},
watch: {
"channel.id"() {
if (autocompletionRef) {
autocompletionRef.hide();
}
},
"channel.pendingMessage"() {
this.setInputSize();
},
},
mounted() {
eventbus.on("escapekey", this.blurInput);
setup(props) {
const store = useStore();
const input = ref<HTMLTextAreaElement>();
const uploadInput = ref<HTMLInputElement>();
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
if (this.$accessor.settings.autocomplete) {
autocompletionRef = autocompletion(this.$refs.input);
}
const inputTrap = Mousetrap(this.$refs.input);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
wrapCursor(
e.target,
modifier,
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (e.target?.selectionStart !== e.target.selectionEnd) {
wrapCursor(e.target, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
this.$accessor.isAutoCompleting ||
e.target.selectionStart !== e.target.selectionEnd
) {
return;
}
const onRow = (
this.$refs.input.value.slice(null, this.$refs.input.selectionStart).match(/\n/g) ||
[]
).length;
const totalRows = (this.$refs.input.value.match(/\n/g) || []).length;
const {channel} = this;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} else if (key === "down" && channel.inputHistoryPosition > 0 && onRow === totalRows) {
channel.inputHistoryPosition--;
} else {
return;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
this.$refs.input.value = channel.pendingMessage;
this.setInputSize();
return false;
});
if (this.$accessor.serverConfiguration.fileUpload) {
upload.mounted();
}
},
unmounted() {
eventbus.off("escapekey", this.blurInput);
if (autocompletionRef) {
autocompletionRef.destroy();
autocompletionRef = null;
}
upload.abort();
},
methods: {
setPendingMessage(e) {
this.channel.pendingMessage = e.target.value;
this.channel.inputHistoryPosition = 0;
this.setInputSize();
},
setInputSize() {
this.$nextTick(() => {
if (!this.$refs.input) {
const setInputSize = () => {
nextTick(() => {
if (!input.value) {
return;
}
const style = window.getComputedStyle(this.$refs.input);
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
const style = window.getComputedStyle(input.value);
const lineHeight = parseFloat(style.lineHeight) || 1;
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
this.$refs.input.style.height = "";
input.value.style.height = "";
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
// because some browsers tend to incorrently round the values when using high density
// displays or using page zoom feature
this.$refs.input.style.height =
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
input.value.style.height = `${
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
}px`;
}).catch(() => {
// no-op
});
},
getInputPlaceholder(channel) {
};
const setPendingMessage = (e: Event) => {
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
props.channel.inputHistoryPosition = 0;
setInputSize();
};
const getInputPlaceholder = (channel: ClientChan) => {
if (channel.type === "channel" || channel.type === "query") {
return `Write to ${channel.name}`;
}
return "";
},
onSubmit() {
};
const onSubmit = () => {
if (!input.value) {
return;
}
// Triggering click event opens the virtual keyboard on mobile
// This can only be called from another interactive event (e.g. button click)
this.$refs.input.click();
this.$refs.input.focus();
input.value.click();
input.value.focus();
if (!this.$accessor.isConnected) {
if (!store.state.isConnected) {
return false;
}
const target = this.channel.id;
const text = this.channel.pendingMessage;
const target = props.channel.id;
const text = props.channel.pendingMessage;
if (text.length === 0) {
return false;
}
if (autocompletionRef) {
autocompletionRef.hide();
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
this.channel.inputHistoryPosition = 0;
this.channel.pendingMessage = "";
this.$refs.input.value = "";
this.setInputSize();
props.channel.inputHistoryPosition = 0;
props.channel.pendingMessage = "";
input.value.value = "";
setInputSize();
// Store new message in history if last message isn't already equal
if (this.channel.inputHistory[1] !== text) {
this.channel.inputHistory.splice(1, 0, text);
if (props.channel.inputHistory[1] !== text) {
props.channel.inputHistory.splice(1, 0, text);
}
// Limit input history to a 100 entries
if (this.channel.inputHistory.length > 100) {
this.channel.inputHistory.pop();
if (props.channel.inputHistory.length > 100) {
props.channel.inputHistory.pop();
}
if (text[0] === "/") {
const args = text.substr(1).split(" ");
const cmd = args.shift().toLowerCase();
const args = text.substring(1).split(" ");
const cmd = args.shift()?.toLowerCase();
if (!cmd) {
return false;
}
if (
Object.prototype.hasOwnProperty.call(commands, cmd) &&
@ -270,23 +196,165 @@ export default defineComponent({
}
socket.emit("input", {target, text});
},
onUploadInputChange() {
const files = Array.from(this.$refs.uploadInput.files);
upload.triggerUpload(files);
this.$refs.uploadInput.value = ""; // Reset <input> element so you can upload the same file
},
openFileUpload() {
this.$refs.uploadInput.click();
},
blurInput() {
this.$refs.input.blur();
},
onBlur() {
if (autocompletionRef) {
autocompletionRef.hide();
};
const onUploadInputChange = () => {
if (!uploadInput.value || !uploadInput.value.files) {
return;
}
},
const files = Array.from(uploadInput.value.files);
upload.triggerUpload(files);
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
};
const openFileUpload = () => {
uploadInput.value?.click();
};
const blurInput = () => {
input.value?.blur();
};
const onBlur = () => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
};
const channelId = ref(props.channel.id);
watch(channelId, () => {
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
});
const pendingMessage = ref(props.channel.pendingMessage);
watch(pendingMessage, () => {
setInputSize();
});
onMounted(() => {
eventbus.on("escapekey", blurInput);
if (store.state.settings.autocomplete) {
if (!input.value) {
throw new Error("ChatInput autocomplete: input element is not available");
}
autocompletionRef.value = autocompletion(input.value);
}
const inputTrap = Mousetrap(input.value);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
if (!e.target) {
return;
}
// TODO; investigate types
wrapCursor(
e.target as HTMLTextAreaElement,
modifier,
(e.target as HTMLTextAreaElement).selectionStart ===
(e.target as HTMLTextAreaElement).selectionEnd
? ""
: modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (
(e.target as HTMLTextAreaElement)?.selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd
) {
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
store.state.isAutoCompleting ||
(e.target as HTMLTextAreaElement).selectionStart !==
(e.target as HTMLTextAreaElement).selectionEnd ||
!input.value
) {
return;
}
const onRow = (
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
[]
).length;
const totalRows = (input.value.value.match(/\n/g) || []).length;
const {channel} = props;
if (channel.inputHistoryPosition === 0) {
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
}
if (key === "up" && onRow === 0) {
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
channel.inputHistoryPosition++;
} else {
return;
}
} else if (
key === "down" &&
channel.inputHistoryPosition > 0 &&
onRow === totalRows
) {
channel.inputHistoryPosition--;
} else {
return;
}
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
input.value.value = channel.pendingMessage;
setInputSize();
return false;
});
if (store.state.serverConfiguration?.fileUpload) {
upload.mounted();
}
});
onUnmounted(() => {
eventbus.off("escapekey", blurInput);
if (autocompletionRef.value) {
autocompletionRef.value.destroy();
autocompletionRef.value = undefined;
}
upload.abort();
});
return {
store,
input,
uploadInput,
onUploadInputChange,
openFileUpload,
blurInput,
onBlur,
channelId,
pendingMessage,
setInputSize,
upload,
getInputPlaceholder,
onSubmit,
setPendingMessage,
};
},
});
</script>

View file

@ -33,7 +33,7 @@ export default defineComponent({
const localeDate = computed(() => dayjs(props.message.time).format("D MMMM YYYY"));
const hoursPassed = () => {
return (Date.now() - Date.parse(props.message.time?.toISOString())) / 3600000;
return (Date.now() - Date.parse(props.message.time.toString())) / 3600000;
};
const dayChange = () => {

View file

@ -42,7 +42,7 @@
import Mousetrap from "mousetrap";
import {computed, defineComponent, ref, watch} from "vue";
import eventbus from "../js/eventbus";
import {ClientChan, ClientMessage, LinkPreview} from "../js/types";
import {ClientChan, ClientMessage, ClientLinkPreview} from "../js/types";
export default defineComponent({
name: "ImageViewer",
@ -50,9 +50,9 @@ export default defineComponent({
const viewer = ref<HTMLDivElement>();
const image = ref<HTMLImageElement>();
const link = ref<LinkPreview | null>(null);
const previousImage = ref<LinkPreview | null>();
const nextImage = ref<LinkPreview | null>();
const link = ref<ClientLinkPreview | null>(null);
const previousImage = ref<ClientLinkPreview | null>();
const nextImage = ref<ClientLinkPreview | null>();
const channel = ref<ClientChan | null>();
const position = ref<{
@ -98,7 +98,7 @@ export default defineComponent({
};
const setPrevNextImages = () => {
if (!channel.value) {
if (!channel.value || !link.value) {
return null;
}
@ -107,7 +107,7 @@ export default defineComponent({
.flat()
.filter((preview) => preview.thumb);
const currentIndex = links.indexOf(this.link);
const currentIndex = links.indexOf(link.value);
previousImage.value = links[currentIndex - 1] || null;
nextImage.value = links[currentIndex + 1] || null;
@ -125,10 +125,6 @@ export default defineComponent({
}
};
const onImageLoad = () => {
prepareImage();
};
const prepareImage = () => {
const viewerEl = viewer.value;
const imageEl = image.value;
@ -148,6 +144,10 @@ export default defineComponent({
transform.value.y = height / 2;
};
const onImageLoad = () => {
prepareImage();
};
const calculateZoomShift = (newScale: number, x: number, y: number, oldScale: number) => {
if (!image.value || !viewer.value) {
return;
@ -241,7 +241,7 @@ export default defineComponent({
// 1. Move around by dragging it with one finger
// 2. Change image scale by using two fingers
const onImageTouchStart = (e: TouchEvent) => {
const image = this.$refs.image;
const img = image.value;
let touch = reduceTouches(e.touches);
let currentTouches = e.touches;
let touchEndFingers = 0;
@ -313,12 +313,12 @@ export default defineComponent({
correctPosition();
image.removeEventListener("touchmove", touchMove, {passive: true});
image.removeEventListener("touchend", touchEnd, {passive: true});
img?.removeEventListener("touchmove", touchMove);
img?.removeEventListener("touchend", touchEnd);
};
image.addEventListener("touchmove", touchMove, {passive: true});
image.addEventListener("touchend", touchEnd, {passive: true});
img?.addEventListener("touchmove", touchMove, {passive: true});
img?.addEventListener("touchend", touchEnd, {passive: true});
};
// Image mouse manipulation:

View file

@ -130,144 +130,177 @@
</template>
<script lang="ts">
import {defineComponent, PropType} from "vue";
import Vue, {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
onUnmounted,
PropType,
ref,
inject,
Ref,
watch,
} from "vue";
import eventbus from "../js/eventbus";
import friendlysize from "../js/helpers/friendlysize";
import type {ClientChan} from "../js/types";
import {useStore} from "../js/store";
import type {ClientChan, ClientLinkPreview} from "../js/types";
import {useImageViewer} from "./App.vue";
export default defineComponent({
name: "LinkPreview",
props: {
link: {
type: Object,
type: Object as PropType<ClientLinkPreview>,
required: true,
},
keepScrollPosition: {
type: Function as PropType<() => void>,
required: true,
},
keepScrollPosition: Function,
channel: {type: Object as PropType<ClientChan>, required: true},
},
data() {
return {
showMoreButton: false,
isContentShown: false,
};
},
computed: {
moreButtonLabel(): string {
return this.isContentShown ? "Less" : "More";
},
imageMaxSize(): string | undefined {
if (!this.link.maxSize) {
setup(props) {
const store = useStore();
const showMoreButton = ref(false);
const isContentShown = ref(false);
const content = ref<HTMLDivElement | null>(null);
const container = ref<HTMLDivElement | null>(null);
const moreButtonLabel = computed(() => {
return isContentShown.value ? "Less" : "More";
});
// TODO: type
const imageMaxSize = computed(() => {
// @ts-ignore
if (!props.link.maxSize) {
return;
}
return friendlysize(this.link.maxSize);
},
},
watch: {
"link.type"() {
this.updateShownState();
this.onPreviewUpdate();
},
},
created() {
this.updateShownState();
},
mounted() {
eventbus.on("resize", this.handleResize);
// @ts-ignore
return friendlysize(props.link.maxSize);
});
this.onPreviewUpdate();
},
beforeUnmount() {
eventbus.off("resize", this.handleResize);
},
unmounted() {
// Let this preview go through load/canplay events again,
// Otherwise the browser can cause a resize on video elements
this.link.sourceLoaded = false;
},
methods: {
onPreviewUpdate() {
// Don't display previews while they are loading on the server
if (this.link.type === "loading") {
return;
}
// Error does not have any media to render
if (this.link.type === "error") {
this.onPreviewReady();
}
// If link doesn't have a thumbnail, render it
if (this.link.type === "link") {
this.handleResize();
this.keepScrollPosition();
}
},
onPreviewReady() {
this.$set(this.link, "sourceLoaded", true);
this.keepScrollPosition();
if (this.link.type === "link") {
this.handleResize();
}
},
onThumbnailError() {
// If thumbnail fails to load, hide it and show the preview without it
this.link.thumb = "";
this.onPreviewReady();
},
onThumbnailClick(e) {
e.preventDefault();
const imageViewer = this.$root.$refs.app.$refs.imageViewer;
imageViewer.channel = this.channel;
imageViewer.link = this.link;
},
onMoreClick() {
this.isContentShown = !this.isContentShown;
this.keepScrollPosition();
},
handleResize() {
this.$nextTick(() => {
if (!this.$refs.content) {
const handleResize = () => {
nextTick(() => {
if (!content.value || !container.value) {
return;
}
this.showMoreButton =
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
}).catch((e) => {
// eslint-disable-next-line no-console
console.error("Error in LinkPreview.handleResize", e);
});
},
updateShownState() {
};
const onPreviewReady = () => {
props.link.sourceLoaded = true;
props.keepScrollPosition();
if (props.link.type === "link") {
handleResize();
}
};
const onPreviewUpdate = () => {
// Don't display previews while they are loading on the server
if (props.link.type === "loading") {
return;
}
// Error does not have any media to render
if (props.link.type === "error") {
onPreviewReady();
}
// If link doesn't have a thumbnail, render it
if (props.link.type === "link") {
handleResize();
props.keepScrollPosition();
}
};
const onThumbnailError = () => {
// If thumbnail fails to load, hide it and show the preview without it
props.link.thumb = "";
onPreviewReady();
};
const onThumbnailClick = (e: MouseEvent) => {
e.preventDefault();
const imageViewer = useImageViewer();
if (!imageViewer.value) {
return;
}
imageViewer.value.channel = props.channel;
imageViewer.value.link = props.link;
};
const onMoreClick = () => {
isContentShown.value = !isContentShown.value;
props.keepScrollPosition();
};
const updateShownState = () => {
// User has manually toggled the preview, do not apply default
if (this.link.shown !== null) {
if (props.link.shown !== null) {
return;
}
let defaultState = false;
switch (this.link.type) {
switch (props.link.type) {
case "error":
// Collapse all errors by default unless its a message about image being too big
if (this.link.error === "image-too-big") {
defaultState = this.$accessor.settings.media;
if (props.link.error === "image-too-big") {
defaultState = store.state.settings.media;
}
break;
case "link":
defaultState = this.$accessor.settings.links;
defaultState = store.state.settings.links;
break;
default:
defaultState = this.$accessor.settings.media;
defaultState = store.state.settings.media;
}
this.link.shown = defaultState;
},
props.link.shown = defaultState;
};
updateShownState();
const linkTypeRef = ref(props.link.type);
watch(linkTypeRef, () => {
updateShownState();
onPreviewUpdate();
});
onMounted(() => {
eventbus.on("resize", handleResize);
onPreviewUpdate();
});
onBeforeUnmount(() => {
eventbus.off("resize", handleResize);
});
onUnmounted(() => {
// Let this preview go through load/canplay events again,
// Otherwise the browser can cause a resize on video elements
props.link.sourceLoaded = false;
});
},
});
</script>

View file

@ -9,12 +9,12 @@
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import {ClientMessage, LinkPreview} from "../js/types";
import {ClientMessage, ClientLinkPreview} from "../js/types";
export default defineComponent({
name: "LinkPreviewToggle",
props: {
link: {type: Object as PropType<LinkPreview>, required: true},
link: {type: Object as PropType<ClientLinkPreview>, required: true},
message: {type: Object as PropType<ClientMessage>, required: true},
},
emits: ["toggle-link-preview"],

View file

@ -78,7 +78,8 @@ import {
watch,
} from "vue";
import {useStore} from "../js/store";
import type {ClientChan, ClientMessage, ClientNetwork, LinkPreview} from "../js/types";
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
import Msg from "../../src/models/msg";
type CondensedMessageContainer = {
type: "condensed";
@ -107,8 +108,8 @@ export default defineComponent({
const historyObserver = ref<IntersectionObserver | null>(null);
const skipNextScrollEvent = ref(false);
const unreadMarkerShown = ref(false);
// TODO: make this a ref?
let isWaitingForNextTick = false;
const isWaitingForNextTick = ref(false);
const jumpToBottom = () => {
skipNextScrollEvent.value = true;
@ -243,7 +244,7 @@ export default defineComponent({
});
const shouldDisplayDateMarker = (
message: ClientMessage | CondensedMessageContainer,
message: Msg | ClientMessage | CondensedMessageContainer,
id: number
) => {
const previousMessage = condensedMessages[id - 1];
@ -271,7 +272,7 @@ export default defineComponent({
return false;
};
const isPreviousSource = (currentMessage: ClientMessage, id: number) => {
const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
const previousMessage = condensedMessages[id - 1];
return !!(
previousMessage &&
@ -291,7 +292,7 @@ export default defineComponent({
const keepScrollPosition = () => {
// If we are already waiting for the next tick to force scroll position,
// we have no reason to perform more checks and set it again in the next tick
if (isWaitingForNextTick) {
if (isWaitingForNextTick.value) {
return;
}
@ -305,10 +306,10 @@ export default defineComponent({
if (props.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop;
isWaitingForNextTick = true;
isWaitingForNextTick.value = true;
nextTick(() => {
isWaitingForNextTick = false;
isWaitingForNextTick.value = false;
skipNextScrollEvent.value = true;
el.scrollTop = el.scrollHeight - heightOld;
}).catch(() => {
@ -319,16 +320,16 @@ export default defineComponent({
return;
}
isWaitingForNextTick = true;
isWaitingForNextTick.value = true;
nextTick(() => {
isWaitingForNextTick = false;
isWaitingForNextTick.value = false;
jumpToBottom();
}).catch(() => {
// no-op
});
};
const onLinkPreviewToggle = (preview: LinkPreview, message: ClientMessage) => {
const onLinkPreviewToggle = (preview: ClientLinkPreview, message: ClientMessage) => {
keepScrollPosition();
// Tell the server we're toggling so it remembers at page reload

View file

@ -199,7 +199,7 @@
import {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
import Mousetrap from "mousetrap";
import Draggable from "vuedraggable";
import {VueDraggableNext} from "vue-draggable-next";
import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue";
@ -220,7 +220,7 @@ export default defineComponent({
JoinChannel,
NetworkLobby,
Channel,
Draggable,
Draggable: VueDraggableNext,
},
setup() {
const store = useStore();
@ -280,7 +280,7 @@ export default defineComponent({
}
if (store.state.activeChannel) {
collapseNetwork(store.state.activeChannel.network, false);
collapseNetworkHelper(store.state.activeChannel.network, false);
}
return false;
@ -533,7 +533,7 @@ export default defineComponent({
scrollToActive,
selectResult,
navigateResults,
onChannelSort,
onNetworkSort,
onDraggableTouchStart,
onDraggableTouchMove,

View file

@ -12,17 +12,14 @@ export default defineComponent({
network: {type: Object as PropType<ClientNetwork>, required: false},
},
setup(props) {
const render = () => {
return parse(
typeof props.text !== "undefined" ? props.text : props.message!.text,
props.message,
props.network
);
};
return {
render,
};
//
},
render(context) {
return parse(
typeof context.text !== "undefined" ? context.text : context.message.text,
context.message,
context.network
);
},
});
</script>

View file

@ -161,7 +161,7 @@ export default defineComponent({
name: "NotificationSettings",
setup() {
const store = useStore();
console.log(store);
const isIOS = computed(
() =>
[

View file

@ -91,7 +91,7 @@ export default defineComponent({
NetworkList,
},
props: {
overlay: {type: Object as PropType<HTMLElement>, required: true},
overlay: {type: Object as PropType<HTMLElement | null>, required: true},
},
setup(props) {
const isDevelopment = process.env.NODE_ENV !== "production";

View file

@ -1,11 +1,19 @@
<template>
<button class="lt" aria-label="Toggle channel list" @click="$store.commit('toggleSidebar')" />
<button class="lt" aria-label="Toggle channel list" @click="store.commit('toggleSidebar')" />
</template>
<script lang="ts">
import {defineComponent} from "vue";
import {useStore} from "../js/store";
export default defineComponent({
name: "SidebarToggle",
setup() {
const store = useStore();
return {
store,
};
},
});
</script>

View file

@ -160,11 +160,11 @@ router.afterEach((to) => {
}
// When switching out of a channel, mark everything as read
if (channel.messages.length > 0) {
if (channel.messages?.length > 0) {
channel.firstUnread = channel.messages[channel.messages.length - 1].id;
}
if (channel.messages.length > 100) {
if (channel.messages?.length > 100) {
channel.messages.splice(0, channel.messages.length - 100);
channel.moreHistoryAvailable = true;
}
@ -172,7 +172,7 @@ router.afterEach((to) => {
});
function navigate(routeName: string, params: any = {}) {
if (router.currentRoute.name) {
if (router.currentRoute.value.name) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
router.push({name: routeName, params}).catch(() => {});
} else {

15
client/js/types.d.ts vendored
View file

@ -27,10 +27,15 @@ type ClientUser = User & {
//
};
type ClientChan = Omit<Chan, "users"> & {
type ClientMessage = Message & {
time: number;
};
type ClientChan = Omit<Chan, "users" | "messages"> & {
moreHistoryAvailable: boolean;
editTopic: boolean;
users: ClientUser[];
messages: ClientMessage[];
// these are added in store/initChannel
pendingMessage: string;
@ -53,10 +58,6 @@ type ClientNetwork = Omit<Network, "channels"> & {
channels: ClientChan[];
};
type ClientMessage = Message & {
//
};
type NetChan = {
channel: ClientChan;
network: ClientNetwork;
@ -68,7 +69,9 @@ type ClientMention = Mention & {
channel: NetChan | null;
};
type LinkPreview = LinkPreview;
type ClientLinkPreview = LinkPreview & {
sourceLoaded?: boolean;
};
declare module "*.vue" {
const Component: ReturnType<typeof defineComponent>;

View file

@ -83,7 +83,3 @@ VueApp.config.errorHandler = function (e) {
// eslint-disable-next-line no-console
console.error(e);
};
VueApp.config.globalProperties = {
$store: store as TypedStore,
};

View file

@ -28,7 +28,7 @@ export type LinkPreview = {
size: number;
link: string; // Send original matched link to the client
shown: boolean | null;
error: undefined | any;
error: undefined | string;
message: undefined | string;
};