Compare commits

...

10 Commits

Author SHA1 Message Date
eal c52173c64b Make babies with the linter. 2017-11-20 16:57:22 +02:00
eal cd87a6712d Add completion for groups. 2017-11-20 16:46:23 +02:00
eal a284a52306 Do a terrible hack to force loading of group state.
Also refractor a bit.
2017-11-20 16:32:31 +02:00
eal caf4c7fddf Fix API calls. 2017-11-19 17:44:50 +02:00
eal 55925383b7 Add group page and card components. 2017-11-19 17:44:35 +02:00
eal 7a3e608455 Add group view to timeline. 2017-11-19 17:43:45 +02:00
eal d4de464ab5 Add group links to status. 2017-11-19 17:42:28 +02:00
eal 54d3b0cd7e Use common method for fetching group and tag timeline. 2017-11-19 17:41:50 +02:00
eal 9c90b019d1 Add groups module. 2017-11-14 22:18:36 +02:00
eal b25b993309 Add API endpoints for groups. 2017-11-14 19:18:17 +02:00
16 changed files with 548 additions and 16 deletions

View File

@ -0,0 +1,225 @@
<template>
<div id="heading" class="group-panel-background" :style="headingStyle">
<div class="panel-heading text-center">
<div class='group-info'>
<div class='container'>
<router-link :to="{ name: 'group-page', params: { name: group.nickname } }">
<img v-if="!!group.original_logo" :src="group.original_logo">
<img v-else src="https://placehold.it/48x48">
</router-link>
<span class="glyphicon glyphicon-user"></span>
<div class="name-and-screen-name">
<div class='group-name'>{{group.nickname}}</div>
<router-link :to="{ name: 'group-page', params: { name: group.nickname } }">
<div class='group-full-name'>{{group.fullname}}</div>
</router-link>
</div>
</div>
<div class="group-interactions">
<div class="member" v-if="loggedIn">
<span v-if="isMember">
<button @click="leaveGroup" class="base04 base00-background pressed">
{{ $t('group_card.leave') }}
</button>
</span>
<span v-if="!isMember">
<button @click="joinGroup" class="base05 base02-background">
{{ $t('group_card.join') }}
</button>
</span>
</div>
</div>
</div>
</div>
<div class="panel-body group-panel-body">
<div class="member-counts">
<div class="member-count">
<a href="#" v-on:click.prevent="setGroupView('statuses')"><h5 class="base05">{{ $t('user_card.statuses') }}</h5></a>
</div>
<div class="member-count">
<a href="#" v-on:click.prevent="setGroupView('members')"><h5 class="base05">{{ $t('group_card.members') }}</h5></a>
<span class="base05">{{group.member_count}}</span>
</div>
</div>
<p>{{group.description}}</p>
</div>
</div>
</template>
<script>
export default {
props: [ 'group', 'isMember' ],
computed: {
groupName () {
return this.group.nickname
},
headingStyle () {
let color = this.$store.state.config.colors['base00']
if (color) {
let rgb = this.$store.state.config.colors['base00'].match(/\d+/g)
return {
backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`,
backgroundImage: `url(${this.group.cover_photo})`
}
}
},
loggedIn () {
return !!this.$store.state.users.currentUser
}
},
methods: {
setMember (value) {
this.$store.state.groups.groupMemberships[this.groupName] = value
},
joinGroup () {
const store = this.$store
store.state.api.backendInteractor.joinGroup({'groupName': this.groupName})
.then((joinedGroup) => {
store.commit('addNewGroup', joinedGroup)
this.setMember(true)
})
},
leaveGroup () {
const store = this.$store
store.state.api.backendInteractor.leaveGroup({'groupName': this.groupName})
.then((leftGroup) => {
store.commit('addNewGroup', leftGroup)
this.setMember(false)
})
},
setGroupView (v) {
const store = this.$store
store.commit('setGroupView', { v })
}
}
}
</script>
<style lang="scss">
@import '../../_variables.scss';
.group-panel-background {
background-size: cover;
border-radius: 10px;
.panel-heading {
padding: 0.6em 0em;
text-align: center;
}
}
.group-panel-body {
top: -0em;
padding-top: 4em;
word-wrap: break-word;
}
.group-info {
color: white;
padding: 0 16px 16px 16px;
margin-bottom: -4em;
.container{
padding: 16px 10px 4px 10px;
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-content: flex-start;
justify-content: center;
max-height: 56px;
overflow: hidden;
}
img {
border-radius: 5px;
flex: 1 0 100%;
width: 56px;
height: 56px;
box-shadow: 0px 1px 8px rgba(0,0,0,0.75);
object-fit: cover;
}
text-shadow: 0px 1px 1.5px rgba(0, 0, 0, 1.0);
.name-and-screen-name {
display: block;
margin-left: 0.6em;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-name{
color: white;
}
.group-full-name {
color: white;
font-weight: lighter;
font-size: 15px;
padding-right: 0.1em;
flex: 0 0 auto;
}
.group-interactions {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
div {
flex: 1;
}
margin-top: 0.7em;
margin-bottom: -1.0em;
.following {
color: white;
font-size: 14px;
flex: 0 0 100%;
margin: -0.7em 0.0em 0.3em 0.0em;
padding-left: 16px;
text-align: left;
}
.mute {
max-width: 220px;
min-height: 28px;
}
.follow {
max-width: 220px;
min-height: 28px;
}
button {
width: 92%;
height: 100%;
}
.pressed {
border-bottom-color: rgba(255, 255, 255, 0.2);
border-top-color: rgba(0, 0, 0, 0.2);
}
}
}
.member-counts {
display: flex;
line-height:16px;
padding: 1em 1.5em 0em 1em;
text-align: center;
}
.member-count {
flex: 1;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}
</style>

View File

@ -0,0 +1,36 @@
import GroupCardContent from '../group_card_content/group_card_content.vue'
import Timeline from '../timeline/timeline.vue'
const GroupPage = {
created () {
this.$store.dispatch('startFetching', { 'timeline': 'group', 'identifier': this.groupName })
},
destroyed () {
this.$store.commit('clearTimeline', { timeline: 'group' })
this.$store.dispatch('stopFetching', 'group')
},
computed: {
timeline () { return this.$store.state.statuses.timelines.group },
groupName () {
return this.$route.params.name
},
group () {
return this.$store.state.groups.groupsObject[this.groupName]
},
isMember () {
return this.$store.state.groups.groupMemberships[this.groupName]
}
},
watch: {
groupName () {
this.$store.commit('clearTimeline', { timeline: 'group' })
this.$store.dispatch('startFetching', { 'timeline': 'group', 'identifier': this.groupName })
}
},
components: {
GroupCardContent,
Timeline
}
}
export default GroupPage

View File

@ -0,0 +1,21 @@
<template>
<div>
<div v-if="group" class="group-page panel panel-default base00-background">
<group-card-content :group="group" :isMember="isMember"></group-card-content>
</div>
<Timeline :title="'Group Timeline'" :group="group" v-bind:timeline="timeline" v-bind:timeline-name="'group'" :groupName="groupName" />
</div>
</template>
<script src="./group_page.js"></script>
<style lang="scss">
.group-page {
flex: 2;
flex-basis: 500px;
padding-bottom: 10px;
border-radius: 10px;
}
</style>

View File

@ -74,6 +74,18 @@ const PostStatusForm = {
name: '',
img: image_url
}))
} else if (firstchar === '!') {
const matchedGroups = filter(this.groups, (group) => group.nickname.match(this.textAtCaret.slice(1)))
if (matchedGroups.length <= 0) {
return false
}
/* eslint-disable */
return map(take(matchedGroups, 5), ({nickname, original_logo}) => ({
screen_name: `!${nickname}`,
name: '',
img: original_logo || 'https://placehold.it/48x48'
}))
/* eslint-enable */
} else {
return false
}
@ -90,6 +102,9 @@ const PostStatusForm = {
},
emoji () {
return this.$store.state.config.emoji || []
},
groups () {
return this.$store.state.groups.groups
}
},
methods: {

View File

@ -54,6 +54,11 @@
{{status.in_reply_to_screen_name}}
</router-link>
</small>
<small v-for="group in status.statusnet_in_groups"> |
<router-link :to="{ name: 'group-page', params: { name: group.nickname } }">
{{group.nickname}}
</router-link>
</small>
<template v-if="isReply">
<small>
<a href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"><i class="icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i></a>

View File

@ -3,7 +3,7 @@ import Timeline from '../timeline/timeline.vue'
const TagTimeline = {
created () {
this.$store.commit('clearTimeline', { timeline: 'tag' })
this.$store.dispatch('startFetching', { 'tag': this.tag })
this.$store.dispatch('startFetching', ['identifier', this.tag])
},
components: {
Timeline
@ -15,7 +15,7 @@ const TagTimeline = {
watch: {
tag () {
this.$store.commit('clearTimeline', { timeline: 'tag' })
this.$store.dispatch('startFetching', { 'tag': this.tag })
this.$store.dispatch('startFetching', ['identifier', this.tag])
}
},
destroyed () {

View File

@ -9,6 +9,7 @@ const Timeline = {
'timelineName',
'title',
'userId',
'groupName',
'tag'
],
data () {
@ -24,6 +25,9 @@ const Timeline = {
friends () {
return this.timeline.friends
},
members () {
return this.timeline.members
},
viewing () {
return this.timeline.viewing
},
@ -49,7 +53,7 @@ const Timeline = {
timeline: this.timelineName,
showImmediately,
userId: this.userId,
tag: this.tag
identifier: this.tag || this.groupName
})
// don't fetch followers for public, friend, twkn
@ -57,6 +61,9 @@ const Timeline = {
this.fetchFriends()
this.fetchFollowers()
}
if (this.timelineName === 'group') {
this.fetchGroup()
}
},
destroyed () {
window.removeEventListener('scroll', this.scrollLoad)
@ -77,7 +84,7 @@ const Timeline = {
older: true,
showImmediately: true,
userId: this.userId,
tag: this.tag
identifier: this.tag || this.groupName
}).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false }))
},
fetchFollowers () {
@ -90,6 +97,17 @@ const Timeline = {
this.$store.state.api.backendInteractor.fetchFriends({ id })
.then((friends) => this.$store.dispatch('addFriends', { friends }))
},
fetchGroup () {
const ident = this.groupName
this.$store.dispatch('fetchGroup', { 'groupName': ident })
this.$store.dispatch('fetchIsMember', { 'groupName': ident, 'id': this.$store.state.users.currentUser.id })
this.$store.state.api.backendInteractor.fetchMembers({ 'groupName': ident })
.then((members) => {
this.$store.dispatch('addMembers', { members })
this.$router.push('/groups/temp') // TODO FIX THIS
this.$router.push(`/groups/${ident}`) // ;_;
})
},
scrollLoad (e) {
let height = Math.max(document.body.offsetHeight, document.body.scrollHeight)
if (this.timeline.loading === false &&

View File

@ -48,6 +48,18 @@
</div>
</div>
</div>
<div class="timeline panel panel-default" v-else-if="viewing == 'members'">
<div class="panel-heading timeline-heading base01-background base04">
<div class="title">
{{$t('group_card.members')}}
</div>
</div>
<div class="panel-body base02-background">
<div class="timeline">
<user-card v-for="member in members" :user="member" :showFollows="false"></user-card>
</div>
</div>
</div>
</template>
<script src="./timeline.js"></script>

View File

@ -158,6 +158,11 @@ const en = {
followees: 'Following',
per_day: 'per day'
},
group_card: {
members: 'Members',
join: 'Join group',
leave: 'Leave group'
},
timeline: {
show_new: 'Show new',
error_fetching: 'Error fetching updates',

View File

@ -9,12 +9,14 @@ import TagTimeline from './components/tag_timeline/tag_timeline.vue'
import ConversationPage from './components/conversation-page/conversation-page.vue'
import Mentions from './components/mentions/mentions.vue'
import UserProfile from './components/user_profile/user_profile.vue'
import GroupPage from './components/group_page/group_page.vue'
import Settings from './components/settings/settings.vue'
import Registration from './components/registration/registration.vue'
import UserSettings from './components/user_settings/user_settings.vue'
import statusesModule from './modules/statuses.js'
import usersModule from './modules/users.js'
import groupsModule from './modules/groups.js'
import apiModule from './modules/api.js'
import configModule from './modules/config.js'
@ -55,6 +57,7 @@ const store = new Vuex.Store({
modules: {
statuses: statusesModule,
users: usersModule,
groups: groupsModule,
api: apiModule,
config: configModule
},
@ -70,6 +73,7 @@ const routes = [
{ path: '/tag/:tag', component: TagTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'user-profile', path: '/users/:id', component: UserProfile },
{ name: 'group-page', path: '/groups/:name', component: GroupPage },
{ name: 'mentions', path: '/:username/mentions', component: Mentions },
{ name: 'settings', path: '/settings', component: Settings },
{ name: 'registration', path: '/registration', component: Registration },

59
src/modules/groups.js Normal file
View File

@ -0,0 +1,59 @@
import { compact, map, each, merge } from 'lodash'
export const mergeOrAdd = (arr, obj, item) => {
if (!item) { return false }
const oldItem = obj[item.nickname]
if (oldItem) {
// We already have this, so only merge the new info.
merge(oldItem, item)
return {item: oldItem, new: false}
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.nickname] = item
return {item, new: true}
}
}
export const defaultState = {
groups: [],
groupsObject: {},
groupMemberships: {}
}
const groups = {
state: defaultState,
mutations: {
addNewGroups (state, statuses) {
each(statuses, (groups) => {
each(groups, (group) => mergeOrAdd(state.groups, state.groupsObject, group))
})
},
addNewGroup (state, group) {
mergeOrAdd(state.groups, state.groupsObject, group)
if (!state.groupMemberships[group.nickname]) {
// insert some fake placeholder data
state.groupMemberships[group.nickname] = {'is_member': false}
}
},
addMembership (state, {groupName, membership}) {
state.groupMemberships[groupName] = membership
}
},
actions: {
fetchGroup (store, { groupName }) {
store.rootState.api.backendInteractor.fetchGroup({ groupName })
.then((group) => store.commit('addNewGroup', group))
},
fetchIsMember (store, { groupName, id }) {
store.rootState.api.backendInteractor.fetchIsMember({id, groupName})
.then((membership) => store.commit('addMembership', {groupName, 'membership': membership.is_member}))
},
addNewStatuses (store, { statuses }) {
const groups = compact(map(statuses, 'statusnet_in_groups'))
store.commit('addNewGroups', groups)
}
}
}
export default groups

View File

@ -93,6 +93,21 @@ export const defaultState = {
followers: [],
friends: [],
viewing: 'statuses'
},
group: {
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
maxId: 0,
minVisibleId: 0,
loading: false,
members: [],
followers: [],
friends: [],
viewing: 'statuses'
}
}
}
@ -412,12 +427,18 @@ export const mutations = {
// load followers / friends only when needed
state.timelines['user'].viewing = v
},
setGroupView (state, { v }) {
state.timelines['group'].viewing = v
},
addFriends (state, { friends }) {
state.timelines['user'].friends = friends
},
addFollowers (state, { followers }) {
state.timelines['user'].followers = followers
},
addMembers (state, { members }) {
state.timelines['group'].members = members
},
markNotificationsAsSeen (state, notifications) {
each(notifications, (notification) => {
notification.seen = true
@ -440,6 +461,9 @@ const statuses = {
addFollowers ({ rootState, commit }, { followers }) {
commit('addFollowers', { followers })
},
addMembers ({ rootState, commit }, { members }) {
commit('addMembers', { members })
},
deleteStatus ({ rootState, commit }, status) {
commit('setDeleted', { status })
apiService.deleteStatus({ id: status.id, credentials: rootState.users.currentUser.credentials })

View File

@ -29,6 +29,14 @@ const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json'
const BLOCKING_URL = '/api/blocks/create.json'
const UNBLOCKING_URL = '/api/blocks/destroy.json'
const USER_URL = '/api/users/show.json'
const GROUP_URL = '/api/statusnet/groups/show'
const GROUP_TIMELINE_URL = '/api/statusnet/groups/timeline'
const GROUP_JOINING_URL = '/api/statusnet/groups/join'
const GROUP_LEAVING_URL = '/api/statusnet/groups/leave'
const GROUP_CREATE_URL = '/api/statusnet/groups/create.json'
const USER_MEMBERSHIPS_URL = '/api/statusnet/groups/list.json'
const GROUP_MEMBERS_URL = '/api/statusnet/groups/membership'
const GROUP_IS_MEMBER_URL = '/api/statusnet/groups/is_member.json'
import { each, map } from 'lodash'
import 'whatwg-fetch'
@ -254,14 +262,15 @@ const setUserMute = ({id, credentials, muted = true}) => {
})
}
const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, tag = false}) => {
const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false, identifier = false}) => {
const timelineUrls = {
public: PUBLIC_TIMELINE_URL,
friends: FRIENDS_TIMELINE_URL,
mentions: MENTIONS_URL,
'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL,
user: QVITTER_USER_TIMELINE_URL,
tag: TAG_TIMELINE_URL
tag: TAG_TIMELINE_URL,
group: GROUP_TIMELINE_URL
}
let url = timelineUrls[timeline]
@ -277,8 +286,8 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use
if (userId) {
params.push(['user_id', userId])
}
if (tag) {
url += `/${tag}.json`
if (identifier) {
url += `/${identifier}.json`
}
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
@ -358,6 +367,65 @@ const fetchMutes = ({credentials}) => {
}).then((data) => data.json())
}
const fetchGroup = ({ groupName }) => {
const url = `${GROUP_URL}/${groupName}.json`
return fetch(url).then((data) => data.json())
}
const joinGroup = ({groupName, credentials}) => {
const url = `${GROUP_JOINING_URL}/${groupName}.json`
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const leaveGroup = ({groupName, credentials}) => {
const url = `${GROUP_LEAVING_URL}/${groupName}.json`
return fetch(url, {
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const createGroup = ({params, credentials}) => {
const form = new FormData()
each(params, (value, key) => {
if (value) {
form.append(key, value)
}
})
return fetch(GROUP_CREATE_URL, {
method: 'POST',
body: form
})
}
const fetchMemberships = ({id, credentials}) => {
const url = `${USER_MEMBERSHIPS_URL}?user_id=${id}`
return fetch(url, {
headers: authHeaders(credentials)
}).then((data) => data.json())
}
const fetchMembers = ({ groupName }) => {
const url = `${GROUP_MEMBERS_URL}/${groupName}.json`
return fetch(url).then((data) => data.json())
}
const fetchIsMember = ({id, groupName}) => {
const url = `${GROUP_IS_MEMBER_URL}?user_id=${id}&group_name=${groupName}`
return fetch(url).then((data) => data.json())
}
const apiService = {
verifyCredentials,
fetchTimeline,
@ -384,7 +452,14 @@ const apiService = {
updateBg,
updateProfile,
updateBanner,
externalProfile
externalProfile,
fetchGroup,
joinGroup,
leaveGroup,
createGroup,
fetchMemberships,
fetchMembers,
fetchIsMember
}
export default apiService

View File

@ -50,6 +50,32 @@ const backendInteractorService = (credentials) => {
return apiService.setUserMute({id, muted, credentials})
}
const fetchGroup = ({ groupName }) => {
return apiService.fetchGroup({ groupName })
}
const joinGroup = ({ groupName }) => {
return apiService.joinGroup({groupName, credentials})
}
const leaveGroup = ({ groupName }) => {
return apiService.leaveGroup({groupName, credentials})
}
const fetchMemberships = ({ id }) => {
return apiService.fetchMemberships({id, credentials})
}
const fetchMembers = ({ groupName }) => {
return apiService.fetchMembers({ groupName })
}
const fetchIsMember = ({id, groupName}) => {
return apiService.fetchIsMember({id, groupName})
}
const createGroup = (params) => apiService.createGroup({params, credentials})
const fetchMutes = () => apiService.fetchMutes({credentials})
const register = (params) => apiService.register(params)
@ -80,7 +106,14 @@ const backendInteractorService = (credentials) => {
updateBg,
updateBanner,
updateProfile,
externalProfile
externalProfile,
fetchGroup,
joinGroup,
leaveGroup,
createGroup,
fetchMemberships,
fetchMembers,
fetchIsMember
}
return backendInteractorServiceInstance

View File

@ -37,7 +37,7 @@ export const addPositionToWords = (words) => {
export const splitIntoWords = (str) => {
// Split at word boundaries
const regex = /\b/
const triggers = /[@#:]+$/
const triggers = /[@#:!]+$/
let split = str.split(regex)

View File

@ -14,7 +14,7 @@ const update = ({store, statuses, timeline, showImmediately}) => {
})
}
const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, tag = false}) => {
const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false, identifier = false}) => {
const args = { timeline, credentials }
const rootState = store.rootState || store.state
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
@ -26,16 +26,16 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false
}
args['userId'] = userId
args['tag'] = tag
args['identifier'] = identifier
return apiService.fetchTimeline(args)
.then((statuses) => update({store, statuses, timeline, showImmediately}),
() => store.dispatch('setError', { value: true }))
}
const startFetching = ({timeline = 'friends', credentials, store, userId = false, tag = false}) => {
fetchAndUpdate({timeline, credentials, store, showImmediately: true, userId, tag})
const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId, tag })
const startFetching = ({timeline = 'friends', credentials, store, userId = false, identifier = false}) => {
fetchAndUpdate({timeline, credentials, store, showImmediately: true, userId, identifier})
const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, showImmediately: false, userId, identifier })
return setInterval(boundFetchAndUpdate, 10000)
}
const timelineFetcher = {