feat: add openinapp plugin to open chat links in shared neko instance (#682)

* feat: add openinapp plugin to open chat links in shared neko instance

* lint.

* feat(openinapp): implement open link handler and route for opening links.

* feat(openinapp): switched to http backend and removed example in docker-compose.yaml

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Paul Troeger
2026-08-09 22:53:39 +02:00
committed by GitHub
parent 365944d1ea
commit cc014571cc
16 changed files with 338 additions and 4 deletions
+31 -1
View File
@@ -18,7 +18,11 @@
<span>{{ member(message.id).displayname }}</span>
<span class="timestamp">{{ timestamp(message.created) }}</span>
</div>
<neko-markdown class="content-body" :source="message.content" />
<neko-markdown
class="content-body"
:source="message.content"
:open-in-app="$accessor.remote.hosting && $accessor.openinapp.enabled"
/>
</div>
</li>
<li :key="index" class="event" v-if="message.type === 'event'">
@@ -225,6 +229,11 @@
display: block;
}
}
.open-in-app {
margin-left: 0.3em;
cursor: pointer;
}
}
}
@@ -460,6 +469,27 @@
target.parentElement.classList.add('active')
event.preventDefault()
}
if (target.classList.contains('open-in-app')) {
const href = target.dataset.href
if (href) {
this.$accessor.openinapp.sendOpenLink(href)
}
event.preventDefault()
}
if (
this.$accessor.settings.links_in_app &&
this.$accessor.openinapp.enabled &&
this.$accessor.remote.hosting &&
target.tagName.toLowerCase() === 'a'
) {
const href = target.getAttribute('href')
if (href) {
this.$accessor.openinapp.sendOpenLink(href)
event.preventDefault()
}
}
}
onKeyDown(event: KeyboardEvent) {
+24 -2
View File
@@ -136,12 +136,21 @@ const rules: MarkdownRules = {
}
},
html(node, output, state) {
return htmlTag(
const href = md.sanitizeUrl(node.target) as string
const link = htmlTag(
'a',
output(node.content, state),
{ href: md.sanitizeUrl(node.target) as string, target: '_blank' },
state,
)
if (!state.openInApp) return link
const inAppIcon = htmlTag(
'i',
'',
{ class: 'open-in-app fas fa-arrow-up-right-from-square', 'data-href': href },
state,
)
return link + inAppIcon
},
},
url: {
@@ -158,12 +167,21 @@ const rules: MarkdownRules = {
}
},
html(node, output, state) {
return htmlTag(
const href = md.sanitizeUrl(node.target) as string
const link = htmlTag(
'a',
output(node.content, state),
{ href: md.sanitizeUrl(node.target) as string, target: '_blank' },
state,
)
if (!state.openInApp) return link
const inAppIcon = htmlTag(
'i',
'',
{ class: 'open-in-app fas fa-arrow-right-to-bracket', 'data-href': href },
state,
)
return link + inAppIcon
},
},
strike: {
@@ -257,12 +275,16 @@ export default class extends Vue {
@Prop({ required: true })
source!: string
@Prop({ default: false })
openInApp!: boolean
render(h: any) {
const state: MarkdownState = {
inline: true,
inQuote: false,
escapeHTML: true,
cssModuleNames: null,
openInApp: this.openInApp,
}
return h({ template: `<div>${htmlOutput(parser(this.source, state), state)}</div>` })
}
+15
View File
@@ -35,6 +35,13 @@
<span />
</label>
</li>
<li v-if="$accessor.openinapp.enabled">
<span>Always open Links in App</span>
<label class="switch">
<input type="checkbox" v-model="links_in_app" />
<span />
</label>
</li>
<li>
<span>{{ $t('setting.keyboard_layout') }}</span>
<label class="select">
@@ -358,6 +365,14 @@
this.$accessor.settings.setSound(value)
}
get links_in_app() {
return this.$accessor.settings.links_in_app
}
set links_in_app(value: boolean) {
this.$accessor.settings.setLinksInApp(value)
}
get keyboard_layouts_list() {
return this.$accessor.settings.keyboard_layouts_list
}
+7
View File
@@ -45,6 +45,10 @@ export const EVENT = {
LIST: 'filetransfer/list',
REFRESH: 'filetransfer/refresh',
},
OPENINAPP: {
INIT: 'openinapp/init',
OPENLINK: 'openinapp/openlink',
},
SCREEN: {
CONFIGURATIONS: 'screen/configurations',
RESOLUTION: 'screen/resolution',
@@ -78,6 +82,7 @@ export type WebSocketEvents =
| SignalEvents
| ChatEvents
| FileTransferEvents
| OpenInAppEvents
| ScreenEvents
| BroadcastEvents
| AdminEvents
@@ -104,6 +109,8 @@ export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type FileTransferEvents = typeof EVENT.FILETRANSFER.LIST | typeof EVENT.FILETRANSFER.REFRESH
export type OpenInAppEvents = typeof EVENT.OPENINAPP.INIT | typeof EVENT.OPENINAPP.OPENLINK
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
export type BroadcastEvents =
+7
View File
@@ -375,6 +375,13 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
this.$accessor.files.setUserDelete(user_delete)
}
/////////////////////////////
// Open in App Events
/////////////////////////////
protected [EVENT.OPENINAPP.INIT]({ enabled }: { enabled: boolean }) {
this.$accessor.openinapp.setEnabled(enabled)
}
/////////////////////////////
// Screen Events
/////////////////////////////
+2 -1
View File
@@ -8,6 +8,7 @@ import { get, set } from '~/utils/localstorage'
import * as video from './video'
import * as chat from './chat'
import * as files from './files'
import * as openinapp from './openinapp'
import * as remote from './remote'
import * as user from './user'
import * as settings from './settings'
@@ -111,7 +112,7 @@ export const storePattern = {
mutations,
actions,
getters,
modules: { video, chat, files, user, remote, settings, client, emoji },
modules: { video, chat, files, openinapp, user, remote, settings, client, emoji },
}
Vue.use(Vuex)
+29
View File
@@ -0,0 +1,29 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store'
export const namespaced = true
export const state = () => ({
enabled: false,
})
export const getters = getterTree(state, {
//
})
export const mutations = mutationTree(state, {
setEnabled(state, enabled: boolean) {
state.enabled = enabled
},
})
export const actions = actionTree(
{ state, getters, mutations },
{
sendOpenLink(store, url: string) {
if (!accessor.connected) return
$client.sendMessage(EVENT.OPENINAPP.OPENLINK, { text: url })
},
},
)
+6
View File
@@ -16,6 +16,7 @@ export const state = () => {
autoplay: get<boolean>('autoplay', true),
ignore_emotes: get<boolean>('ignore_emotes', false),
chat_sound: get<boolean>('chat_sound', true),
links_in_app: get<boolean>('links_in_app', false),
keyboard_layout: get<string>('keyboard_layout', 'us'),
keyboard_layouts_list: {} as KeyboardLayouts,
@@ -53,6 +54,11 @@ export const mutations = mutationTree(state, {
set('chat_sound', value)
},
setLinksInApp(state, value: boolean) {
state.links_in_app = value
set('links_in_app', value)
},
setKeyboardLayout(state, value: string) {
state.keyboard_layout = value
set('keyboard_layout', value)