Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85629309
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
15 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/package.json b/package.json
index f28fb3b7fd..d04c3e221f 100644
--- a/package.json
+++ b/package.json
@@ -1,96 +1,97 @@
{
"name": "pleroma_fe",
"version": "1.0.0",
"description": "A Qvitter-style frontend for certain GS servers.",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
"scripts": {
"dev": "node build/dev-server.js",
"build": "node build/build.js",
"unit": "karma start test/unit/karma.conf.js --single-run",
+ "unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
"babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-lodash": "^3.2.11",
"diff": "^3.0.1",
"karma-mocha-reporter": "^2.2.1",
"localforage": "^1.5.0",
"node-sass": "^3.10.1",
"object-path": "^0.11.3",
"sanitize-html": "^1.13.0",
"sass-loader": "^4.0.2",
"vue": "^2.3.4",
"vue-router": "^2.5.3",
"vue-template-compiler": "^2.3.4",
"vue-timeago": "^3.1.2",
"vuex": "^2.3.1"
},
"devDependencies": {
"autoprefixer": "^6.4.0",
"babel-core": "^6.0.0",
"babel-eslint": "^7.0.0",
"babel-loader": "^6.0.0",
"babel-plugin-transform-runtime": "^6.0.0",
"babel-preset-es2015": "^6.0.0",
"babel-preset-stage-2": "^6.0.0",
"babel-register": "^6.0.0",
"chai": "^3.5.0",
"chalk": "^1.1.3",
"chromedriver": "^2.21.2",
"connect-history-api-fallback": "^1.1.0",
"cross-spawn": "^4.0.2",
"css-loader": "^0.25.0",
"eslint": "^3.7.1",
"eslint-config-standard": "^6.1.0",
"eslint-friendly-formatter": "^2.0.5",
"eslint-loader": "^1.5.0",
"eslint-plugin-html": "^1.5.5",
"eslint-plugin-promise": "^2.0.1",
"eslint-plugin-standard": "^2.0.1",
"eventsource-polyfill": "^0.9.6",
"express": "^4.13.3",
"extract-text-webpack-plugin": "^1.0.1",
"file-loader": "^0.9.0",
"function-bind": "^1.0.2",
"html-webpack-plugin": "^2.8.1",
"http-proxy-middleware": "^0.17.2",
"inject-loader": "^2.0.1",
"isparta-loader": "^2.0.0",
"json-loader": "^0.5.4",
"karma": "^1.3.0",
"karma-coverage": "^1.1.1",
"karma-mocha": "^1.2.0",
"karma-phantomjs-launcher": "^1.0.0",
"karma-sinon-chai": "^1.2.0",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26",
"karma-webpack": "^1.7.0",
"lodash": "^4.16.4",
"lolex": "^1.4.0",
"mocha": "^3.1.0",
"nightwatch": "^0.9.8",
"opn": "^4.0.2",
"ora": "^0.3.0",
"phantomjs-prebuilt": "^2.1.3",
"raw-loader": "^0.5.1",
"selenium-server": "2.53.1",
"semver": "^5.3.0",
"shelljs": "^0.7.4",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",
"url-loader": "^0.5.7",
"vue-loader": "^11.1.0",
"vue-style-loader": "^2.0.0",
"webpack": "^1.13.2",
"webpack-dev-middleware": "^1.8.3",
"webpack-hot-middleware": "^2.12.2",
"webpack-merge": "^0.14.1"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
}
}
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index df4c7bafab..a8b4d39cb3 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,105 +1,138 @@
import statusPoster from '../../services/status_poster/status_poster.service.js'
import MediaUpload from '../media_upload/media_upload.vue'
import fileTypeService from '../../services/file_type/file_type.service.js'
-
-import { reject, map, uniqBy } from 'lodash'
+import Completion from '../../services/completion/completion.js'
+import { take, filter, reject, map, uniqBy } from 'lodash'
const buildMentionsString = ({user, attentions}, currentUser) => {
let allAttentions = [...attentions]
allAttentions.unshift(user)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, {id: currentUser.id})
let mentions = map(allAttentions, (attention) => {
return `@${attention.screen_name}`
})
return mentions.join(' ') + ' '
}
const PostStatusForm = {
props: [
'replyTo',
'repliedUser',
'attentions'
],
components: {
MediaUpload
},
data () {
let statusText = ''
if (this.replyTo) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
return {
dropFiles: [],
submitDisabled: false,
newStatus: {
status: statusText,
files: []
- }
+ },
+ caret: 0
}
},
computed: {
+ candidates () {
+ if (this.textAtCaret.charAt(0) === '@') {
+ const matchedUsers = filter(this.users, (user) => (user.name + user.screen_name).match(this.textAtCaret.slice(1)))
+ if (matchedUsers.length <= 0) {
+ return false
+ }
+ // eslint-disable-next-line camelcase
+ return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}) => ({
+ screen_name: screen_name,
+ name: name,
+ img: profile_image_url_original
+ }))
+ } else {
+ return false
+ }
+ },
+ textAtCaret () {
+ return (this.wordAtCaret || {}).word || ''
+ },
+ wordAtCaret () {
+ const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {}
+ return word
+ },
users () {
return this.$store.state.users.users
}
},
methods: {
+ replace (replacement) {
+ this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement)
+ const el = this.$el.querySelector('textarea')
+ el.focus()
+ this.caret = 0
+ },
+ setCaret ({target: {selectionStart}}) {
+ this.caret = selectionStart
+ },
postStatus (newStatus) {
statusPoster.postStatus({
status: newStatus.status,
media: newStatus.files,
store: this.$store,
inReplyToStatusId: this.replyTo
})
this.newStatus = {
status: '',
files: []
}
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = '16px'
},
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
this.enableSubmit()
},
removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
},
disableSubmit () {
this.submitDisabled = true
},
enableSubmit () {
this.submitDisabled = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype)
},
fileDrop (e) {
if (e.dataTransfer.files.length > 0) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
}
},
fileDrag (e) {
e.dataTransfer.dropEffect = 'copy'
},
resize (e) {
e.target.style.height = 'auto'
e.target.style.height = `${e.target.scrollHeight - 10}px`
if (e.target.value === '') {
e.target.style.height = '16px'
}
}
}
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 46beb5064e..a95f92ab8c 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,113 +1,152 @@
<template>
<div class="post-status-form">
<form @submit.prevent="postStatus(newStatus)">
<div class="form-group base03-border" >
- <textarea v-model="newStatus.status" placeholder="Just landed in L.A." rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea>
+ <textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" placeholder="Just landed in L.A." rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea>
+ </div>
+ <div style="position:relative;" v-if="candidates">
+ <div class="autocomplete-panel base05-background">
+ <div v-for="candidate in candidates" @click="replace('@' + candidate.screen_name + ' ')" class="autocomplete base01">
+ <img :src="candidate.img"></img>
+ <span>
+ @{{candidate.screen_name}}
+ <small class="base02">{{candidate.name}}</small>
+ </span>
+ </div>
+ </div>
</div>
<div class='form-bottom'>
<media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload>
<button :disabled="submitDisabled" type="submit" class="btn btn-default base05 base01-background">Submit</button>
</div>
<div class="attachments">
<div class="attachment" v-for="file in newStatus.files">
<i class="fa icon-cancel" @click="removeMediaFile(file)"></i>
<img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img>
<video v-if="type(file) === 'video'" :src="file.image" controls></video>
<audio v-if="type(file) === 'audio'" :src="file.image" controls></audio>
<a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a>
</div>
</div>
</form>
</div>
</template>
<script src="./post_status_form.js"></script>
<style lang="scss">
.tribute-container {
ul {
padding: 0px;
li {
display: flex;
align-items: center;
}
}
img {
padding: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
}
}
.post-status-form, .login {
.form-bottom {
display: flex;
padding: 0.5em;
height: 32px;
button {
width: 10em;
}
}
.attachments {
padding: 0 0.5em;
.attachment {
position: relative;
margin: 0.5em 0.8em 0.2em 0;
}
i {
position: absolute;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
border-radius: 5px;
font-weight: bold;
}
}
form {
display: flex;
flex-direction: column;
padding: 0.6em;
}
.form-group {
display: flex;
flex-direction: column;
padding: 0.3em 0.5em 0.6em;
line-height:24px;
}
form textarea {
border: solid;
border-width: 1px;
border-color: inherit;
border-radius: 5px;
line-height:16px;
padding: 5px;
resize: none;
overflow: hidden;
}
form textarea:focus {
min-height: 48px;
}
.btn {
cursor: pointer;
}
.btn[disabled] {
cursor: not-allowed;
}
.icon-cancel {
cursor: pointer;
}
+
+ .autocomplete-panel {
+ margin: 0 0.5em 0 0.5em;
+ border-radius: 5px;
+ position: absolute;
+ z-index: 1;
+ box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5);
+ min-width: 75%;
+ }
+
+ .autocomplete {
+ cursor: pointer;
+ padding: 0.2em 0.4em 0.2em 0.4em;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.4);
+ display: flex;
+ img {
+ width: 24px;
+ height: 24px;
+ border-radius: 2px;
+ }
+ span {
+ line-height: 24px;
+ margin: 0 0.1em 0 0.2em;
+ }
+ small {
+ font-style: italic;
+ }
+ }
}
</style>
diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js
new file mode 100644
index 0000000000..8788d837ff
--- /dev/null
+++ b/src/services/completion/completion.js
@@ -0,0 +1,70 @@
+import { reduce, find } from 'lodash'
+
+export const replaceWord = (str, toReplace, replacement) => {
+ return str.slice(0, toReplace.start) + replacement + str.slice(toReplace.end)
+}
+
+export const wordAtPosition = (str, pos) => {
+ const words = splitIntoWords(str)
+ const wordsWithPosition = addPositionToWords(words)
+
+ return find(wordsWithPosition, ({start, end}) => start <= pos && end > pos)
+}
+
+export const addPositionToWords = (words) => {
+ return reduce(words, (result, word) => {
+ const data = {
+ word,
+ start: 0,
+ end: word.length
+ }
+
+ if (result.length > 0) {
+ const previous = result.pop()
+
+ data.start += previous.end
+ data.end += previous.end
+
+ result.push(previous)
+ }
+
+ result.push(data)
+
+ return result
+ }, [])
+}
+
+export const splitIntoWords = (str) => {
+ // Split at word boundaries
+ const regex = /\b/
+ const triggers = /[@#]+$/
+
+ let split = str.split(regex)
+
+ // Add trailing @ and # to the following word.
+ const words = reduce(split, (result, word) => {
+ if (result.length > 0) {
+ let previous = result.pop()
+ const matches = previous.match(triggers)
+ if (matches) {
+ previous = previous.replace(triggers, '')
+ word = matches[0] + word
+ }
+ result.push(previous)
+ }
+ result.push(word)
+
+ return result
+ }, [])
+
+ return words
+}
+
+const completion = {
+ wordAtPosition,
+ addPositionToWords,
+ splitIntoWords,
+ replaceWord
+}
+
+export default completion
diff --git a/test/unit/specs/services/completion/completion.spec.js b/test/unit/specs/services/completion/completion.spec.js
new file mode 100644
index 0000000000..8a41c6537a
--- /dev/null
+++ b/test/unit/specs/services/completion/completion.spec.js
@@ -0,0 +1,70 @@
+import { replaceWord, addPositionToWords, wordAtPosition, splitIntoWords } from '../../../../../src/services/completion/completion.js'
+
+describe('addPositiontoWords', () => {
+ it('adds the position to a word list', () => {
+ const words = ['hey', 'this', 'is', 'fun']
+
+ const expected = [
+ {
+ word: 'hey',
+ start: 0,
+ end: 3
+ },
+ {
+ word: 'this',
+ start: 3,
+ end: 7
+ },
+ {
+ word: 'is',
+ start: 7,
+ end: 9
+ },
+ {
+ word: 'fun',
+ start: 9,
+ end: 12
+ }
+ ]
+
+ const res = addPositionToWords(words)
+
+ expect(res).to.eql(expected)
+ })
+})
+
+describe('splitIntoWords', () => {
+ it('splits at whitespace boundaries', () => {
+ const str = 'This is a #nice @test for you, @idiot.'
+ const expected = ['This', ' ', 'is', ' ', 'a', ' ', '#nice', ' ', '@test', ' ', 'for', ' ', 'you', ', ', '@idiot', '.']
+ const res = splitIntoWords(str)
+
+ expect(res).to.eql(expected)
+ })
+})
+
+describe('wordAtPosition', () => {
+ it('returns the word for a given string and postion, plus the start and end position of that word', () => {
+ const str = 'Hey this is fun'
+
+ const { word, start, end } = wordAtPosition(str, 4)
+
+ expect(word).to.eql('this')
+ expect(start).to.eql(4)
+ expect(end).to.eql(8)
+ })
+})
+
+describe('replaceWord', () => {
+ it('replaces a word (with start and end) with another word in a given string', () => {
+ const str = 'hey @take, how are you'
+ const wordsWithPosition = addPositionToWords(splitIntoWords(str))
+ const toReplace = wordsWithPosition[2]
+
+ expect(toReplace.word).to.eql('@take')
+
+ const expected = 'hey @takeshitakenji, how are you'
+ const res = replaceWord(str, toReplace, '@takeshitakenji')
+ expect(res).to.eql(expected)
+ })
+})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Aug 8, 7:08 PM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1723899
Default Alt Text
(15 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment