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 ContextMenu from "./ContextMenu.vue";
import ConfirmDialog from "./ConfirmDialog.vue"; import ConfirmDialog from "./ConfirmDialog.vue";
import Mentions from "./Mentions.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 {useStore} from "../js/store";
import type {DebouncedFunc} from "lodash"; 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({ export default defineComponent({
name: "App", name: "App",
components: { components: {
@ -51,6 +77,10 @@ export default defineComponent({
const contextMenu = ref(null); const contextMenu = ref(null);
const confirmDialog = ref(null); const confirmDialog = ref(null);
provide(imageViewerKey, imageViewer);
provide(contextMenuKey, contextMenu);
provide(confirmDialogKey, confirmDialog);
const viewportClasses = computed(() => { const viewportClasses = computed(() => {
return { return {
notified: store.getters.highlightCount > 0, notified: store.getters.highlightCount > 0,

View file

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

View file

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

View file

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

View file

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

View file

@ -130,144 +130,177 @@
</template> </template>
<script lang="ts"> <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 eventbus from "../js/eventbus";
import friendlysize from "../js/helpers/friendlysize"; 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({ export default defineComponent({
name: "LinkPreview", name: "LinkPreview",
props: { props: {
link: { link: {
type: Object, type: Object as PropType<ClientLinkPreview>,
required: true,
},
keepScrollPosition: {
type: Function as PropType<() => void>,
required: true, required: true,
}, },
keepScrollPosition: Function,
channel: {type: Object as PropType<ClientChan>, required: true}, channel: {type: Object as PropType<ClientChan>, required: true},
}, },
data() { setup(props) {
return { const store = useStore();
showMoreButton: false,
isContentShown: false, const showMoreButton = ref(false);
}; const isContentShown = ref(false);
},
computed: { const content = ref<HTMLDivElement | null>(null);
moreButtonLabel(): string { const container = ref<HTMLDivElement | null>(null);
return this.isContentShown ? "Less" : "More";
}, const moreButtonLabel = computed(() => {
imageMaxSize(): string | undefined { return isContentShown.value ? "Less" : "More";
if (!this.link.maxSize) { });
// TODO: type
const imageMaxSize = computed(() => {
// @ts-ignore
if (!props.link.maxSize) {
return; return;
} }
return friendlysize(this.link.maxSize); // @ts-ignore
}, return friendlysize(props.link.maxSize);
}, });
watch: {
"link.type"() {
this.updateShownState();
this.onPreviewUpdate();
},
},
created() {
this.updateShownState();
},
mounted() {
eventbus.on("resize", this.handleResize);
this.onPreviewUpdate(); const handleResize = () => {
}, nextTick(() => {
beforeUnmount() { if (!content.value || !container.value) {
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) {
return; return;
} }
this.showMoreButton = showMoreButton.value = content.value.offsetWidth >= container.value.offsetWidth;
this.$refs.content.offsetWidth >= this.$refs.container.offsetWidth;
}).catch((e) => { }).catch((e) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("Error in LinkPreview.handleResize", e); 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 // User has manually toggled the preview, do not apply default
if (this.link.shown !== null) { if (props.link.shown !== null) {
return; return;
} }
let defaultState = false; let defaultState = false;
switch (this.link.type) { switch (props.link.type) {
case "error": case "error":
// Collapse all errors by default unless its a message about image being too big // Collapse all errors by default unless its a message about image being too big
if (this.link.error === "image-too-big") { if (props.link.error === "image-too-big") {
defaultState = this.$accessor.settings.media; defaultState = store.state.settings.media;
} }
break; break;
case "link": case "link":
defaultState = this.$accessor.settings.links; defaultState = store.state.settings.links;
break; break;
default: 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> </script>

View file

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

View file

@ -78,7 +78,8 @@ import {
watch, watch,
} from "vue"; } from "vue";
import {useStore} from "../js/store"; 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 CondensedMessageContainer = {
type: "condensed"; type: "condensed";
@ -107,8 +108,8 @@ export default defineComponent({
const historyObserver = ref<IntersectionObserver | null>(null); const historyObserver = ref<IntersectionObserver | null>(null);
const skipNextScrollEvent = ref(false); const skipNextScrollEvent = ref(false);
const unreadMarkerShown = ref(false); const unreadMarkerShown = ref(false);
// TODO: make this a ref?
let isWaitingForNextTick = false; const isWaitingForNextTick = ref(false);
const jumpToBottom = () => { const jumpToBottom = () => {
skipNextScrollEvent.value = true; skipNextScrollEvent.value = true;
@ -243,7 +244,7 @@ export default defineComponent({
}); });
const shouldDisplayDateMarker = ( const shouldDisplayDateMarker = (
message: ClientMessage | CondensedMessageContainer, message: Msg | ClientMessage | CondensedMessageContainer,
id: number id: number
) => { ) => {
const previousMessage = condensedMessages[id - 1]; const previousMessage = condensedMessages[id - 1];
@ -271,7 +272,7 @@ export default defineComponent({
return false; return false;
}; };
const isPreviousSource = (currentMessage: ClientMessage, id: number) => { const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
const previousMessage = condensedMessages[id - 1]; const previousMessage = condensedMessages[id - 1];
return !!( return !!(
previousMessage && previousMessage &&
@ -291,7 +292,7 @@ export default defineComponent({
const keepScrollPosition = () => { const keepScrollPosition = () => {
// If we are already waiting for the next tick to force scroll position, // 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 // we have no reason to perform more checks and set it again in the next tick
if (isWaitingForNextTick) { if (isWaitingForNextTick.value) {
return; return;
} }
@ -305,10 +306,10 @@ export default defineComponent({
if (props.channel.historyLoading) { if (props.channel.historyLoading) {
const heightOld = el.scrollHeight - el.scrollTop; const heightOld = el.scrollHeight - el.scrollTop;
isWaitingForNextTick = true; isWaitingForNextTick.value = true;
nextTick(() => { nextTick(() => {
isWaitingForNextTick = false; isWaitingForNextTick.value = false;
skipNextScrollEvent.value = true; skipNextScrollEvent.value = true;
el.scrollTop = el.scrollHeight - heightOld; el.scrollTop = el.scrollHeight - heightOld;
}).catch(() => { }).catch(() => {
@ -319,16 +320,16 @@ export default defineComponent({
return; return;
} }
isWaitingForNextTick = true; isWaitingForNextTick.value = true;
nextTick(() => { nextTick(() => {
isWaitingForNextTick = false; isWaitingForNextTick.value = false;
jumpToBottom(); jumpToBottom();
}).catch(() => { }).catch(() => {
// no-op // no-op
}); });
}; };
const onLinkPreviewToggle = (preview: LinkPreview, message: ClientMessage) => { const onLinkPreviewToggle = (preview: ClientLinkPreview, message: ClientMessage) => {
keepScrollPosition(); keepScrollPosition();
// Tell the server we're toggling so it remembers at page reload // 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 {computed, watch, defineComponent, nextTick, onBeforeUnmount, onMounted, ref} from "vue";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import Draggable from "vuedraggable"; import {VueDraggableNext} from "vue-draggable-next";
import {filter as fuzzyFilter} from "fuzzy"; import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue"; import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue"; import Channel from "./Channel.vue";
@ -220,7 +220,7 @@ export default defineComponent({
JoinChannel, JoinChannel,
NetworkLobby, NetworkLobby,
Channel, Channel,
Draggable, Draggable: VueDraggableNext,
}, },
setup() { setup() {
const store = useStore(); const store = useStore();
@ -280,7 +280,7 @@ export default defineComponent({
} }
if (store.state.activeChannel) { if (store.state.activeChannel) {
collapseNetwork(store.state.activeChannel.network, false); collapseNetworkHelper(store.state.activeChannel.network, false);
} }
return false; return false;
@ -533,7 +533,7 @@ export default defineComponent({
scrollToActive, scrollToActive,
selectResult, selectResult,
navigateResults, navigateResults,
onChannelSort,
onNetworkSort, onNetworkSort,
onDraggableTouchStart, onDraggableTouchStart,
onDraggableTouchMove, onDraggableTouchMove,

View file

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

View file

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

View file

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

View file

@ -1,11 +1,19 @@
<template> <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> </template>
<script lang="ts"> <script lang="ts">
import {defineComponent} from "vue"; import {defineComponent} from "vue";
import {useStore} from "../js/store";
export default defineComponent({ export default defineComponent({
name: "SidebarToggle", name: "SidebarToggle",
setup() {
const store = useStore();
return {
store,
};
},
}); });
</script> </script>

View file

@ -160,11 +160,11 @@ router.afterEach((to) => {
} }
// When switching out of a channel, mark everything as read // 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; 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.messages.splice(0, channel.messages.length - 100);
channel.moreHistoryAvailable = true; channel.moreHistoryAvailable = true;
} }
@ -172,7 +172,7 @@ router.afterEach((to) => {
}); });
function navigate(routeName: string, params: any = {}) { function navigate(routeName: string, params: any = {}) {
if (router.currentRoute.name) { if (router.currentRoute.value.name) {
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
router.push({name: routeName, params}).catch(() => {}); router.push({name: routeName, params}).catch(() => {});
} else { } 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; moreHistoryAvailable: boolean;
editTopic: boolean; editTopic: boolean;
users: ClientUser[]; users: ClientUser[];
messages: ClientMessage[];
// these are added in store/initChannel // these are added in store/initChannel
pendingMessage: string; pendingMessage: string;
@ -53,10 +58,6 @@ type ClientNetwork = Omit<Network, "channels"> & {
channels: ClientChan[]; channels: ClientChan[];
}; };
type ClientMessage = Message & {
//
};
type NetChan = { type NetChan = {
channel: ClientChan; channel: ClientChan;
network: ClientNetwork; network: ClientNetwork;
@ -68,7 +69,9 @@ type ClientMention = Mention & {
channel: NetChan | null; channel: NetChan | null;
}; };
type LinkPreview = LinkPreview; type ClientLinkPreview = LinkPreview & {
sourceLoaded?: boolean;
};
declare module "*.vue" { declare module "*.vue" {
const Component: ReturnType<typeof defineComponent>; const Component: ReturnType<typeof defineComponent>;

View file

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

View file

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